SSL routines wrong version number, and the Port That Was Never 443

Share
SSL routines wrong version number, and the Port That Was Never 443. Abstract error autopsy illustration in orange and dark grey on debugly.dev

The error looks like a certificate problem. It is almost never a certificate problem.

error:1408F10B:SSL routines:ssl3_get_record:wrong version number

Or, depending on which client you are using:

curl: (35) error:1408F10B:SSL routines:ssl3_get_record:wrong version number
OpenSSL SSL_connect: SSL_ERROR_SYSCALL in connection to api.internal:443

I have seen engineers spend an afternoon regenerating certificates, rebuilding trust stores and rotating CA bundles over this one. None of that was ever going to help, because the certificate is never read. The handshake fails before anyone asks for one.

This was on nginx 1.27, OpenSSL 3.4, curl 8.5, Node 22.14 and Python 3.13.2. The mechanics are the same on any version, because the failure happens below the TLS library you are blaming.

The short answer

Your client opened a TLS connection. The server answered with plaintext HTTP. The first bytes the client read were the start of an HTTP response, not a TLS record header, so the client tried to interpret HTTP as a protocol version field and reported a version number that makes no sense.

Nothing is wrong with your certificate. Nothing is wrong with your trust store. You are speaking TLS at a port that is not speaking TLS.

Why the message is so misleading

A TLS record starts with a five byte header. The first byte is the content type, and bytes two and three are the legacy protocol version. When a server sends back HTTP/1.1 200 OK, the client reads:

Bytes Client reads as Actual content
48 54 54 50 record header HTTP
content type 0x48 not a valid TLS type the letter H
version 0x54 0x50 protocol version 84.80 the letters TP

The library has no idea it is looking at text. It reports the version field it parsed and calls it wrong, which is technically accurate and uselessly vague. This is a good example of an error message that describes the parser's confusion rather than the caller's mistake, which is the theme of error messages are a user interface.

The causes, in the order I actually hit them

1. You are connecting to a plaintext port

The most common cause by a wide margin. Port 443 is a convention, not a property. If nginx is listening on 80 without an ssl directive, it will happily accept a connection on any port you point at it and answer in plaintext.

Check what is actually listening:

ss -tlnp | grep -E ':(80|443|8443)'
openssl s_client -connect api.internal:443 -servername api.internal

If openssl s_client fails with the same wrong version number, the port is not doing TLS. That is your answer, and you can stop reading.

2. A proxy already terminated TLS and you are re-encrypting

This one bites people running Kubernetes ingress, an AWS ALB or Cloudflare in front of an application. The load balancer terminates TLS on 443 and forwards plaintext to your pod on 8080. Your application then tries to serve HTTPS on 8080, or your health check does.

The fix is to pick one place to terminate. If the proxy does it, the origin speaks HTTP and your service should be configured for HTTP behind the proxy.

3. You set an https:// scheme against a port that serves http://

Very common in configuration that was copy pasted between environments. Staging runs TLS locally, production terminates at the load balancer, and the DATABASE_URL or REDIS_URL still says rediss:// or https:// for a plaintext endpoint.

Grep your config for scheme declarations and check each one against the port it targets:

grep -rEn 'https?://|rediss?://|postgres(ql)?(\+ssl)?://' config/ .env*

4. The server is speaking HTTP/2 cleartext

If a server is configured for h2c, it will send an HTTP/2 preface where your client expects a TLS record. Same symptom, rarer cause. curl --http2-prior-knowledge against the port will confirm it.

5. An actual protocol downgrade or middlebox

Genuinely rare, but real. A corporate proxy, a transparent firewall or a misconfigured ssl_protocols directive that excludes everything your client supports. If openssl s_client -tls1_2 and -tls1_3 both fail differently from the plain attempt, look here.

The two minute diagnosis

Run these in order. One of them will end the investigation.

# 1. Is anything listening, and on what?
ss -tlnp | grep 443

# 2. Does the port speak TLS at all?
openssl s_client -connect host:443 -servername host </dev/null 2>&1 | head -5

# 3. What does it actually send back?
printf 'GET / HTTP/1.1\r\nHost: host\r\n\r\n' | timeout 3 nc host 443 | head -3

# 4. Where are the bytes going?
sudo tcpdump -ni any -A 'host host and port 443' -c 20

Step 3 is the one that settles it. If you get HTTP/1.1 back from a TLS port, the server is plaintext and no amount of certificate work will change that.

Prevention

Make the scheme and the port a single value rather than two values that have to agree. A BASE_URL of https://api.internal:8443 cannot drift out of sync the way separate SCHEME, HOST and PORT variables can.

Add a startup check that performs one real TLS handshake against every endpoint the service talks to, and fails loudly at boot rather than at the first request. A handshake at startup costs thirty milliseconds and saves an afternoon.

And when you see wrong version number, do not touch the certificate. Check the port first. It is the port nine times out of ten, and the tenth time it is a proxy that already did the work for you.

If the port is right and the handshake still fails, the problem has moved on to the certificate chain itself, which is a different investigation covered in x509 certificate signed by unknown authority.