How HTTP Works

Debug guide · you're seeing

  • This site can't provide a secure connection. example.com sent an invalid response. ERR_SSL_PROTOCOL_ERROR
  • ERR_SSL_PROTOCOL_ERROR
  • SSL_ERROR_RX_RECORD_TOO_LONG
  • curl: (35) OpenSSL/3.0.13: error:0A00010B:SSL routines::wrong version number
  • error:0A00010B:SSL routines:tls_validate_record_header:wrong version number

ERR_SSL_PROTOCOL_ERROR: Causes and Fixes in Chrome

Fix ERR_SSL_PROTOCOL_ERROR: plain HTTP on port 443, TLS version mismatches, SNI, TLS-inspecting proxies and Cloudflare, with curl and openssl checks.

Reviewed 11 min readintermediate9 sourcesTry itMarkdown
On this page

TL;DR: Chrome started a TLS handshake and got back something that is not valid TLS. The most common cause by far is a port serving plain HTTP: nginx listen 443; without ssl, or https://localhost:3000 against a dev server. Run openssl s_client -connect HOST:443 -servername HOST; wrong version number confirms plain HTTP. If the handshake works from your machine but fails for some users, look for a TLS-inspecting proxy, antivirus or firewall on their network.

What it means

Chrome shows:

This site can't provide a secure connection
example.com sent an invalid response.
ERR_SSL_PROTOCOL_ERROR

This is not an HTTP error. No request was sent, so there is no status code and nothing useful in your access log (with one exception, covered below). ERR_SSL_PROTOCOL_ERROR (net error -107) is Chrome’s catch-all: when BoringSSL reports a handshake failure that Chrome has no more specific code for, it lands here. Several neighbours have their own codes, and seeing one of them instead narrows the search:

Chrome errorWhat actually happened
ERR_SSL_PROTOCOL_ERRORBytes that are not TLS, an unexpected alert, or a broken handshake
ERR_SSL_VERSION_OR_CIPHER_MISMATCHNo shared TLS version or cipher suite (TLS 1.0/1.1-only servers land here)
ERR_SSL_UNRECOGNIZED_NAME_ALERTThe server has no site for the SNI hostname and said so
ERR_CONNECTION_RESETA TCP RST arrived during the handshake (see ERR_CONNECTION_RESET)
ERR_CONNECTION_CLOSEDThe server closed the connection (TCP FIN) mid-handshake
NET::ERR_CERT_*The handshake worked, but the certificate failed (certificate errors)

The same failure in other clients:

Firefox:  Secure Connection Failed ... SSL_ERROR_RX_RECORD_TOO_LONG   (plain HTTP on an HTTPS port)
Firefox:  Secure Connection Failed ... PR_END_OF_FILE_ERROR           (connection closed during the handshake)
curl:     curl: (35) OpenSSL/3.0.13: error:0A00010B:SSL routines::wrong version number
Node.js:  Error: write EPROTO ...:SSL routines:tls_validate_record_header:wrong version number

Who sent it?

Whoever answers TCP on that port: your web server, a load balancer, a CDN, or a box in the middle that the client does not know about. Three quick checks tell you which:

# 1. What does the port actually speak?
openssl s_client -connect example.com:443 -servername example.com </dev/null

# 2. Who issued the certificate the client sees? Run it from a working and a failing network.
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -issuer -subject

# 3. Is the hostname behind a CDN? A cf-ray header means Cloudflare terminates TLS.
curl -sI https://example.com/ | grep -iE '^(server|cf-ray|via|x-amz-cf-id)'

If the issuer on the failing network is a firewall or antivirus vendor rather than your CA, the connection is being intercepted and the server is not the problem.

Fix it, in order of likelihood

1. HTTPS to a port that speaks plain HTTP

This is the cause most of the time, and the error text is misleading because nothing about TLS is wrong: the server never attempted TLS. Chrome sends a ClientHello, the server reads it as a malformed HTTP request and replies HTTP/1.1 400 Bad Request in clear text. Chrome tries to parse HTTP/ as a TLS record header and gives up.

That also explains the odd error names elsewhere. The first five bytes of a TLS record are a content type, a two-byte version and a two-byte length. In HTTP/, the TT sits where the version should be (so OpenSSL says wrong version number), and P/ decodes to a length of 20,527 bytes, larger than TLS allows (so Firefox says SSL_ERROR_RX_RECORD_TOO_LONG).

Confirm with openssl. On OpenSSL 3.x:

openssl s_client -connect example.com:443 -servername example.com </dev/null
80A1BFF901000000:error:0A00010B:SSL routines:tls_validate_record_header:wrong version number:ssl/record/methods/tlsany_meth.c:78:
CONNECTED(00000005)
---
no peer certificate available
---
SSL handshake has read 5 bytes and written 1540 bytes

read 5 bytes and no peer certificate available are the tell: the server sent one bogus record header and no certificate. Older OpenSSL 3.0 builds name the function ssl3_get_record instead; the wrong version number part is stable. One trap: the system curl on macOS is built against LibreSSL and reports this same situation as curl: (35) LibreSSL/3.3.6: error:1404B42E:SSL routines:ST_CONNECT:tlsv1 alert protocol version, which sends people chasing TLS versions that have nothing to do with it. Trust openssl s_client from Homebrew or a Linux box.

Plain HTTP on 443 is also the one case that leaves a trace in the server’s access log, because the server did parse a “request”. In nginx it looks like this (request bytes trimmed):

203.0.113.7 - - [04/Oct/2026:10:12:01 +0000] "\x16\x03\x01\x07\x1A\x01\x00\x07\x16\x03\x03" 400 157 "-" "-"

\x16 is the TLS handshake record type and \x03\x01 the legacy version field of a ClientHello. A line like this means a client spoke TLS to a port that served HTTP.

Where it comes from:

  • nginx listen 443; without ssl. nginx loads the config happily and serves HTTP on port 443. Without the ssl parameter, the ssl_certificate lines in that block are ignored for that listener.

    server {
        listen 443 ssl;          # "listen 443;" here serves plain HTTP on 443
        listen [::]:443 ssl;
        server_name example.com;
    
        ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
        ssl_protocols       TLSv1.2 TLSv1.3;
    }

    The reverse mistake, listen 443 ssl with no certificate, does not reach users: current nginx refuses to start with no "ssl_certificate" is defined for the "listen ... ssl" directive. Check with grep -rn 'listen.*443' /etc/nginx/ and look for any 443 listener missing ssl, including the IPv6 one.

  • Apache <VirtualHost *:443> without SSLEngine on. Same symptom.

  • A local dev server. https://localhost:3000 against Express, Vite, Next.js or Django’s runserver, which speak plain HTTP unless told otherwise. Use http://, or enable HTTPS in the dev server (for example next dev --experimental-https, or Vite’s server.https option with a certificate).

  • Chrome forcing HTTPS on a host you serve over HTTP. If anything on localhost ever sent Strict-Transport-Security, Chrome upgrades every later http://localhost request to HTTPS and hits your plain dev server. Delete the entry under Delete domain security policies at chrome://net-internals/#hsts. Entries from the HSTS preload list cannot be deleted: every .dev and .app domain is preloaded, so a dev hostname under those TLDs always needs real TLS.

  • An HTTPS proxy setting that is not HTTPS. HTTPS_PROXY=https://proxy.internal:3128 against a proxy that speaks plain HTTP gives curl and most CLIs the same wrong version number. The scheme in the proxy URL is how the client talks to the proxy, so it is usually http://.

  • A port or redirect mix-up. An app that redirects to https://example.com:8080/ where 8080 is plain HTTP, or a load balancer listener on 443 forwarding raw TCP to a backend port that expects TLS on another port.

2. TLS version or cipher mismatch

A server that only offers TLS 1.0 or 1.1 does not usually produce ERR_SSL_PROTOCOL_ERROR. Chrome stopped connecting to those servers entirely in Chrome 98 (after a click-through warning period starting in Chrome 84), and it reports them as ERR_SSL_VERSION_OR_CIPHER_MISMATCH with the text “example.com uses an unsupported protocol”. RFC 8996 formally deprecates both versions. The same code appears when the server and browser share no cipher suite.

You get ERR_SSL_PROTOCOL_ERROR from version problems when the server mishandles a modern ClientHello instead of refusing it cleanly: an old TLS stack or appliance that sends an unexpected alert, or garbage, in reply to TLS 1.3. Probe each version separately:

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

A server that refuses TLS 1.3 cleanly answers with a protocol_version alert:

error:0A00042E:SSL routines:ssl3_read_bytes:tlsv1 alert protocol version:ssl/record/rec_layer_s3.c:918:SSL alert number 70

and the TLS 1.2 probe succeeds with New, TLSv1.2, Cipher is ECDHE-RSA-AES256-GCM-SHA384. That server is fine for Chrome. If both probes fail, test the legacy versions. OpenSSL 3 disables TLS 1.0 and 1.1 at its default security level, so lower it for the probe:

openssl s_client -connect example.com:443 -servername example.com -tls1 -cipher 'DEFAULT:@SECLEVEL=0' </dev/null

For the full picture of what the server accepts, with a letter grade per cipher suite:

nmap --script ssl-enum-ciphers -p 443 example.com

The output lists each protocol version the server accepts (TLSv1.0:, TLSv1.2: and so on) with its cipher suites, plus warnings such as 64-bit block cipher 3DES vulnerable to SWEET32 attack. The fix is to enable TLS 1.2 and 1.3 with ECDHE and AES-GCM or ChaCha20 suites: in nginx ssl_protocols TLSv1.2 TLSv1.3;, in Apache SSLProtocol -all +TLSv1.2 +TLSv1.3.

3. Broken SNI, or no site for this hostname

On a shared IP the server picks the site and certificate from the Server Name Indication the client sends in the ClientHello (SNI, RFC 6066 section 3). When the hostname is not configured, what the browser sees depends on the server:

  • nginx with ssl_reject_handshake on in the default server sends a fatal unrecognized_name alert, which Chrome reports as ERR_SSL_UNRECOGNIZED_NAME_ALERT, not ERR_SSL_PROTOCOL_ERROR.
  • Servers that abort with internal_error or another generic alert, or that drop the connection, produce ERR_SSL_PROTOCOL_ERROR.
  • Servers that fall back to a default certificate for another site produce NET::ERR_CERT_COMMON_NAME_INVALID instead.

Compare the handshake with and without SNI:

openssl s_client -connect 203.0.113.10:443 -servername example.com </dev/null 2>&1 | grep -E 'alert|subject=|Protocol'
openssl s_client -connect 203.0.113.10:443 -noservername </dev/null 2>&1 | grep -E 'alert|subject=|Protocol'

If the first fails and the second works, the server has a TLS site but not for example.com: add the hostname to server_name in the TLS server block (or the matching vhost, ingress host or load balancer certificate list). If you test by IP address, always pass -servername, because browsers never send an IP as SNI.

4. A middlebox, antivirus or corporate proxy intercepting TLS

When the site works from your network and fails for some users, something between them and you is terminating or inspecting TLS. Typical sources:

  • Corporate TLS inspection (next-generation firewalls, secure web gateways) that cannot parse something in Chrome’s ClientHello. Chrome’s post-quantum key share made the ClientHello too large for one packet: Chrome turned on a hybrid Kyber key share by default on desktop in Chrome 124 and moved to the standardized X25519MLKEM768 in Chrome 131, and inspection devices that assumed the ClientHello arrives in a single packet broke. Fortinet, for example, documented ERR_SSL_PROTOCOL_ERROR with flow-based deep inspection and ML-KEM. Google offered the PostQuantumKeyAgreementEnabled enterprise policy as a temporary escape hatch and said it would be removed after Chrome 145; the lasting fix is a firmware update from the vendor.
  • Antivirus HTTPS scanning that re-signs traffic, especially older versions that mishandle TLS 1.3.
  • A firewall blocking the site with a TLS alert. Some firewalls send an access_denied alert when they block a page. Per the spec that alert is only for client certificates, so Chrome maps it to ERR_SSL_PROTOCOL_ERROR when it never received a certificate request. Cloudflare Zero Trust Gateway block pages produce the same error when the Cloudflare root certificate is not installed on the device.

To prove interception, compare the certificate issuer from the failing machine with the one from your own (check 2 under “Who sent it?”). Then test from the same machine with security software paused, or over a phone hotspot or VPN. If the error disappears off the corporate network, take it to whoever owns the inspection policy, with a Chrome net log from chrome://net-export.

5. Cloudflare: what SSL mode does and does not affect

The SSL/TLS encryption mode controls only the Cloudflare-to-origin connection. In Flexible mode Cloudflare talks to your origin over plain HTTP; in Full and Full (strict) it uses HTTPS. A mismatch there does not produce ERR_SSL_PROTOCOL_ERROR in the visitor’s browser, because the browser’s handshake is with Cloudflare. It produces a Cloudflare error page instead:

  • Full or Full (strict) with an origin that has no working TLS on 443 gives 525 SSL handshake failed (or 521 if nothing listens). An origin with listen 443; without ssl is a classic 525.
  • Flexible with an origin that redirects HTTP to HTTPS gives a redirect loop (ERR_TOO_MANY_REDIRECTS), because Cloudflare keeps fetching the origin over HTTP. Cloudflare’s docs say not to use Flexible when the origin forces HTTPS.
  • Flexible applies only to HTTPS on port 443. Cloudflare falls back to Full mode for HTTPS on other ports, so a non-standard port suddenly needs TLS on the origin.

When the browser itself shows ERR_SSL_PROTOCOL_ERROR on a Cloudflare hostname, the browser-to-edge handshake failed. Cloudflare’s troubleshooting page lists, in order:

  1. The edge certificate is not active yet (a recently added domain waiting for Universal SSL), or the hostname is a multi-level subdomain such as dev.docs.example.com, which Universal SSL does not cover.
  2. Intermittent failures for some visitors: temporarily turn off HTTP/3 (with QUIC) under Protocol Optimization. If that fixes it, the visitor’s network blocks or mangles UDP on port 443.
  3. Failures on corporate networks or with antivirus HTTPS scanning: temporarily turn off TLS 1.3 under SSL/TLS > Edge Certificates. If that fixes it, a middlebox on the visitor’s side cannot handle TLS 1.3. Turn it back on afterwards; this is a diagnostic, not a fix.
  4. Ask the visitor for the output of https://example.com/cdn-cgi/trace, which shows which Cloudflare data center, TLS version and HTTP version they reach.

Also check whether the DNS record is proxied. A DNS-only (grey cloud) record sends the browser straight to your origin, so every origin cause above applies, and Cloudflare’s certificate is not involved at all.

Reproduce and verify

# Full handshake as curl sees it; look at the lines starting with "*"
curl -v https://example.com/ -o /dev/null

# Speak plain HTTP to the TLS port and see what comes back
curl -si --max-time 5 http://example.com:443/ | head -n 12

# Handshake details: protocol, cipher, certificate subject
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | grep -E '^(New|Protocol|Verify return code)|subject='

The plain-HTTP probe separates the two cases. If port 443 returns your normal page (a 200, or the redirect your site usually sends), it is serving plain HTTP and you have found the problem. A correctly configured nginx TLS port answers that probe with 400 Bad Request and the body text The plain HTTP request was sent to HTTPS port, which is the 497 case seen from the other side. Other TLS servers close the connection, and curl prints Empty reply from server.

A fixed server shows New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384 (or a TLS 1.2 ECDHE suite) and Verify return code: 0 (ok). Then reload the page in Chrome; if the error persists only in the browser, clear the HSTS entry and test in a fresh profile, so a cached HSTS or certificate decision is not hiding the fix.

Frequently asked questions

Why do I get ERR_SSL_PROTOCOL_ERROR on https://localhost?

Your dev server speaks plain HTTP on that port, and Chrome is sending it a TLS ClientHello. The server answers with something like "HTTP/1.1 400 Bad Request", which Chrome cannot parse as TLS. Use http://localhost:3000, or start the dev server with HTTPS enabled. If Chrome keeps forcing https:// for localhost, delete the HSTS entry at chrome://net-internals/#hsts.

What does "wrong version number" mean in curl or openssl?

The first bytes the server sent were not a TLS record. OpenSSL reads the response's first five bytes as a record header, and the "TT" in "HTTP/" lands where the protocol version should be, so it reports a wrong version. In practice it almost always means the port is serving plain HTTP, or an HTTPS_PROXY setting points at a proxy that does not speak TLS.

Does a server that only supports TLS 1.0 or 1.1 cause ERR_SSL_PROTOCOL_ERROR?

Usually not. Chrome removed TLS 1.0 and 1.1 in Chrome 98 and reports those servers as ERR_SSL_VERSION_OR_CIPHER_MISMATCH ("uses an unsupported protocol"). ERR_SSL_PROTOCOL_ERROR is the catch-all Chrome uses when the TLS exchange breaks in a way that has no more specific error code, such as garbage bytes, an unexpected alert or a middlebox fault.

Can Cloudflare SSL mode cause ERR_SSL_PROTOCOL_ERROR?

Not directly. The SSL mode (Flexible, Full, Full strict) only controls the Cloudflare-to-origin connection, and failures there show as Cloudflare error pages such as 521, 525 or 526, or as a redirect loop with Flexible. If the browser shows ERR_SSL_PROTOCOL_ERROR on a proxied hostname, the browser-to-Cloudflare handshake failed: check that the edge certificate is active, that the hostname is not a multi-level subdomain, and whether TLS 1.3 or HTTP/3 is tripping a middlebox on the visitor's network.

Why does the site work on one network but show ERR_SSL_PROTOCOL_ERROR on another?

Something on the failing network is touching the TLS connection: a corporate firewall doing TLS inspection, antivirus HTTPS scanning, or an ISP filter. Compare the certificate issuer with openssl s_client from both networks. If the issuer on the failing network is a security vendor instead of your CA, the connection is being intercepted.

Sources

  1. MDN: Transport Layer Security (TLS)developer.mozilla.org
  2. RFC 8446 Section 6: Alert Protocol (TLS 1.3)rfc-editor.org
  3. RFC 8996: Deprecating TLS 1.0 and TLS 1.1rfc-editor.org
  4. RFC 6066 Section 3: Server Name Indicationrfc-editor.org
  5. Chromium source: net_error_list.hsource.chromium.org
  6. Cloudflare Docs: Troubleshoot ERR_SSL_PROTOCOL_ERRORdevelopers.cloudflare.com
  7. Cloudflare Docs: Flexible SSL modedevelopers.cloudflare.com
  8. nginx: ngx_http_ssl_modulenginx.org
  9. Chrome Enterprise: PostQuantumKeyAgreementEnabled policychromeenterprise.google

Keep going

Browse /search