# 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.

Source: https://howhttpworks.com/debug/err-ssl-protocol-error
Last reviewed: 2026-10-04

Error messages this page covers:
- `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`

> **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:

```text
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 error | What actually happened |
|---|---|
| `ERR_SSL_PROTOCOL_ERROR` | Bytes that are not TLS, an unexpected alert, or a broken handshake |
| `ERR_SSL_VERSION_OR_CIPHER_MISMATCH` | No shared TLS version or cipher suite (TLS 1.0/1.1-only servers land here) |
| `ERR_SSL_UNRECOGNIZED_NAME_ALERT` | The server has no site for the SNI hostname and said so |
| `ERR_CONNECTION_RESET` | A TCP RST arrived during the handshake (see [ERR_CONNECTION_RESET](https://howhttpworks.com/debug/err-connection-reset)) |
| `ERR_CONNECTION_CLOSED` | The server closed the connection (TCP FIN) mid-handshake |
| `NET::ERR_CERT_*` | The handshake worked, but the certificate failed ([certificate errors](https://howhttpworks.com/debug/err-cert-common-name-invalid)) |

The same failure in other clients:

```text
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:

```bash
# 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:

```bash
openssl s_client -connect example.com:443 -servername example.com </dev/null
```

```text
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):

```text
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.

  ```nginx
  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:

```bash
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:

```text
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:

```bash
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:

```bash
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](https://howhttpworks.com/glossary/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`](https://howhttpworks.com/debug/err-cert-common-name-invalid) instead.

Compare the handshake with and without SNI:

```bash
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](https://howhttpworks.com/status-codes/525) (or [521](https://howhttpworks.com/status-codes/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](https://howhttpworks.com/debug/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

```bash
# 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](https://howhttpworks.com/status-codes/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.

## Related

- [NET::ERR_CERT_COMMON_NAME_INVALID](https://howhttpworks.com/debug/err-cert-common-name-invalid) is the next error you meet once the handshake works but the certificate does not match.
- [ERR_CONNECTION_RESET](https://howhttpworks.com/debug/err-connection-reset) covers handshakes killed by a TCP RST rather than a TLS alert.
- [525 SSL Handshake Failed](https://howhttpworks.com/status-codes/525) is the same class of failure on Cloudflare's leg to your origin.
- [497 HTTP Request Sent to HTTPS Port](https://howhttpworks.com/status-codes/497) is the mirror image: plain HTTP sent to a TLS port.
- [TLS handshake](https://howhttpworks.com/glossary/tls-handshake) and [HTTPS and TLS](https://howhttpworks.com/guides/https-and-tls) explain what each step of the handshake does.
