ObservabilityLXIV · TLS MonitoringTLSMonitoring
TLS Handshake Failure
What you'll learn
- Recognise the three dominant TLS handshake failure shapes from production traces
- Wire probe_failed into Prometheus alongside probe_success and probe_ssl_earliest_cert_expiry
- Use openssl s_client to reproduce a handshake failure and read the failure reason
- Distinguish a chain-of-trust failure from a SAN-mismatch failure from an expiry failure
Prerequisites
Verified against Prometheus 2.55.x · Alertmanager 0.28.x · node_exporter 1.8.x · blackbox_exporter 0.26.x · Grafana 11.x · Loki 3.x · Tempo current · OpenTelemetry Collector 0.110.x · Grafana Alloy current · Docker Engine 28.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-13
A page that loaded fine at 14:00 returned ERR_CERT_DATE_INVALID
to every browser at 14:05. The on-call engineer opened the
expiry dashboard and saw “60 days remaining” on the leaf
certificate. The handshake-failure metric, which the previous
team had disabled “because it was noisy,” had gone silent. The
actual fault was a chain: the leaf was valid, the intermediate
was valid, but the intermediate had been replaced by the CA
without the team noticing. The old intermediate was still on
disk; the new one was not. The browser walked the chain, did
not find a valid path to a trusted root, and refused the page.
That incident is the reason this lesson exists. Expiry is one failure shape. Handshake failure has three.
What it is
A TLS handshake failure is any outcome in which the client and server cannot agree on the parameters of an encrypted session within the configured timeout. From the client’s perspective, the connection is refused or terminated during the handshake phase — before any application data is exchanged. From the server’s perspective, the connection either does not arrive (TCP refused) or is closed before the application accepts it.
probe_failed is the blackbox_exporter metric that captures
this. The metric is a gauge with value 1 when the probe fails,
and either absent or 0 when it succeeds. Combined with
probe_success (a gauge that is 1 on success), the two form
the canonical handshake health signal:
probe_failed{instance="https://api.example.com:443"} 1
probe_success{instance="https://api.example.com:443"} 0
Three failure shapes account for the overwhelming majority of production handshake failures:
- Expired certificate. The leaf or an intermediate cert has
a
notAfterin the past. Browsers and most clients refuse the connection withERR_CERT_DATE_INVALID. - Wrong host (SAN mismatch). The certificate is valid but
was issued for a different hostname. The client presents
api.example.com; the server’s certificate is forapi-staging.example.com. Browsers refuse withERR_CERT_COMMON_NAME_INVALID(modern browsers useNET::ERR_CERT_AUTHORITY_INVALIDafter SAN-only enforcement). - Untrusted CA / incomplete chain. The leaf is valid and
matches the hostname, but the chain does not validate against
the client’s trust store. The most common cause is a missing
intermediate certificate. Browsers refuse with
ERR_CERT_AUTHORITY_INVALID.
A fourth shape, protocol or cipher mismatch, accounts for a small fraction of failures today. The lesson on protocol health (04) covers it.
Why a sysadmin cares
A handshake failure is a hard failure. There is no degraded
mode. The client sees a wall in the browser, a Connection reset in the API consumer, or a TLS handshake timeout in
the service mesh. Every minute of a handshake outage costs the
business the same as a hard outage of the application.
Three reasons teams underestimate the operational cost:
- The expiry alert says “60 days” so the team assumes the cert is fine. The expiry alert watches the leaf. The handshake can still fail because of the chain.
- The application health check returns 200. The application is up; the TLS terminator in front of it is broken. The two have to be tested independently.
- The 4xx / 5xx dashboard is flat. The connection never reaches the application. There is no HTTP status code to alert on. The failure is visible only at the TLS layer.
The handshake-failure metric is the one that catches all three.
How it works
The TLS handshake is a small state machine. The high-level flow for TLS 1.2 (TLS 1.3 collapses the first two flights but the failure modes are the same):
Client Server
| |
|--- ClientHello (cipher suites, ----->|
| supported versions, SNI) |
| |
|<-- ServerHello (chosen cipher, -----|
| chosen version) |
| |
|<-- Certificate (chain) ------|
| |
|<-- ServerHelloDone -----|
| |
| client validates chain, hostname, |
| expiry, revocation |
| |
|--- ClientKeyExchange ------------> |
|--- ChangeCipherSpec -------------> |
|--- Finished ---------------------> |
| |
|<-- ChangeCipherSpec ------------- |
|<-- Finished --------------------- |
| |
| application data |
The handshake fails at any of the validation points:
Failure points
|
ClientHello / ServerHello ----> +-- Protocol mismatch
| (client offers TLS 1.3 only,
| server picks TLS 1.0)
|
Certificate chain --------->----+-- Chain does not validate
| against the client's trust
| store (untrusted CA, missing
| intermediate)
|
Certificate notAfter ---->------+-- Cert is expired
|
Certificate SAN ------->--------+-- Hostname not in SAN list
|
Finished ------------->---------+-- Signature mismatch,
downgrade detection
The blackbox exporter’s probe_failed captures the outcome:
did the handshake complete? It does not capture the reason.
The reason has to be reconstructed from openssl s_client
output, server logs, or a more sophisticated probe (lesson 04
introduces testssl.sh).
Under the hood
How to configure it
The blackbox module that exposes both handshake success and
failure (/etc/blackbox_exporter/config.yml):
modules:
http_tls_health:
prober: http
timeout: 10s
http:
# Probe fails on any non-2xx, but we want a TLS-layer
# signal too. Use preferred_ip_protocol: ip4 to avoid
# IPv6 surprises in mixed environments.
preferred_ip_protocol: ip4
tls: true
tls_config:
min_version: TLS12
max_version: TLS13
# DO NOT skip verification here. We want probe_failed
# to fire on chain and SAN problems. The expiry
# metric from lesson 01 uses a separate module with
# skip_verify: true.
insecure_skip_verify: false
tcp_tls_handshake:
prober: tcp
timeout: 10s
tcp:
tls: true
tls_config:
min_version: TLS12
max_version: TLS13
insecure_skip_verify: false
The http_tls_health module combines handshake validation
with an HTTP check; a 5xx response does not affect
probe_failed. The tcp_tls_handshake module is a pure
handshake test and is useful for non-HTTP services (databases,
message brokers, custom protocols).
The Prometheus scrape job (/etc/prometheus/prometheus.yml):
scrape_configs:
- job_name: blackbox_tls_handshake
metrics_path: /probe
params:
module: [http_tls_health]
scrape_interval: 1m # tighter than expiry; see below
scrape_timeout: 15s
static_configs:
- targets:
- https://api.example.com
- https://checkout.example.com
- https://portal.example.com
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: 127.0.0.1:9115
A 1m scrape interval is appropriate here. The handshake-failure metric is a current state signal: the moment a certificate breaks, every probe must see it. A 1h scrape interval (used for expiry) means a 59-minute blind spot during which the page is broken. The probe is cheap (one handshake, ~100 ms); the shorter interval is justified.
The alert rule (/etc/prometheus/rules/tls_alerts.yml):
groups:
- name: tls_handshake_alerts
interval: 1m
rules:
- alert: TLSHandshakeFailing
expr: probe_failed{job="blackbox_tls_handshake"} == 1
for: 3m
labels:
severity: critical
category: tls
annotations:
summary: 'TLS handshake failing on {{ $labels.instance }}'
description: 'TLS handshake has been failing for 3 minutes on {{ $labels.instance }}. Probable cause: expired cert, SAN mismatch, or untrusted CA chain. Check with openssl s_client -connect {{ $labels.instance | replace ":" " " | replace " " " " }} -servername {{ $labels.instance | replace ":443" "" }}.'
runbook_url: 'https://runbooks.example.com/tls/handshake-failure'
Three minutes is the right for duration. Shorter and the
alert fires on TCP retransmits; longer and the page is broken
for too long.
How to validate it
Three checks: confirm the metric is emitted, reproduce a failure, and confirm the alert fires.
Confirm the metric:
curl -s 'http://127.0.0.1:9115/probe?module=tcp_tls_handshake&target=api.example.com:443' \
| grep -E '^probe_(failed|success|ssl_earliest)'
Realistic output on a healthy target:
probe_failed 0
probe_success 1
probe_ssl_earliest_cert_expiry 1.789e+09
probe_ssl_last_chain_elements 3
probe_duration_seconds 0.118
Reproduce a failure with openssl:
openssl s_client -connect api-staging.example.com:443 \
-servername api.example.com 2>&1 | head -30
This intentionally presents the wrong SNI to a server whose
certificate is for api-staging.example.com. Realistic output:
CONNECTED(00000005)
depth=2 O = Digital Signature Trust Co., CN = DST Root CA X3
verify return:1
depth=1 C = US, O = Let's Encrypt, CN = R3
verify return:1
depth=0 CN = api-staging.example.com
verify return:1
---
Certificate chain
0 s:CN = api-staging.example.com
i:CN = R3
---
Server certificate
subject=CN = api-staging.example.com
issuer=CN = R3
---
Verify return code: 62 (Hostname mismatch)
The last line — Hostname mismatch — is the answer. The
depth=0 CN = api-staging.example.com confirms what the server
sent. The SNI mismatch is the failure shape.
Reproduce an expired-cert failure against a known-expired endpoint (test against a deliberately-expired staging endpoint, never against a real one):
echo | openssl s_client -connect expired.example.com:443 2>&1 | grep -E 'verify|notAfter'
Realistic output:
verify return:1
verify return:1
depth=0 CN = expired.example.com
verify error:num=10:certificate has expired
notAfter=Aug 13 23:59:59 2025 GMT
num=10 is X509_V_ERR_CERT_HAS_EXPIRED in OpenSSL. The
notAfter is in the past. The probe should report
probe_failed 1 within the next scrape interval.
Reproduce an untrusted-CA failure with a self-signed cert:
echo | openssl s_client -connect selfsigned.example.com:443 2>&1 | grep verify
Realistic output:
verify error:num=20:unable to get local issuer certificate
num=20 is X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY. The
chain does not validate. The probe fires probe_failed 1.
Confirm the alert fires by injecting a synthetic alert (lesson 02):
amtool alert add alertname=TLSHandshakeFailing \
severity=critical category=tls \
instance=test.example.com
Remove after testing.
How it can fail
Five failure modes appear in production.
-
The probe target is wrong. The scrape job points at
https://api.example.com(no port), which resolves to a CDN that refuses TLS 1.0. Symptom: every probe fails with “protocol version not supported.” The application is fine; the probe is wrong. -
insecure_skip_verify: falseis set but the trust store on the blackbox host is outdated. The probe fails with “untrusted CA” for certificates issued by a new root. The application, on a host with an updated trust store, succeeds. Symptom: dashboard says handshake failure; real clients do not see it. -
The target is an IP address, not a hostname. The probe cannot validate SAN against an IP. Symptom: SAN-only certificates report
Hostname mismatchagainst the probe but work in browsers (which use the URL hostname). Fix: probe the hostname, not the IP. -
The probe module is
http_2xxand the application is returning 5xx. Theprobe_faileddoes not fire (TLS is fine);probe_http_status_codereports 503. The on-call engineer chasing a “handshake failure” looks in the wrong place. Thehttp_tls_healthmodule above ishttpnothttp_2xx, so the TLS and HTTP signals stay separate. -
The server is rate-limiting the probe source IP. The server returns
TLS Alert: internal error(a generic alert) when the rate limit is exceeded. The probe reports handshake failure. The application is fine. Symptom: intermittent failures correlated with scrape time.
How to troubleshoot it
The diagnostic order matches the failure shapes.
- Reproduce from the same network as the blackbox host.
openssl s_client -connect <target> -servername <hostname>. The-servernameflag is critical; without it, you are testing the default certificate, which may differ. - Read the
verify return code. The codes are listed inman x509underVerify return code. Common ones:0—X509_V_OK, success.10—X509_V_ERR_CERT_HAS_EXPIRED, expiry.18—X509_V_ERR_DEPTH_ZERO_SELF_SIGNED_CERT, self-signed.20—X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY, untrusted CA / missing intermediate.62—X509_V_ERR_HOSTNAME_MISMATCH, SAN mismatch.72—X509_V_ERR_CERT_SIGNATURE_FAILURE, signature does not verify (corrupt or forged).
- Inspect the full chain.
openssl s_client -showcertsto see every cert the server sent. Missing intermediate is the most common production shape; the server sent only the leaf. - Cross-check with the application. From a host that runs
the application,
openssl s_clientagain. A different result means the issue is on the blackbox host (trust store, DNS, routing), not on the server. - Check the server logs. nginx, Apache, haproxy, and
Envoy all log handshake failures with reasons. The OpenSSL
verify return codemaps to a server-side log line. - Check revocation.
openssl s_clientdoes not check OCSP by default. Add-statusto query the OCSP responder. A revoked certificate is a handshake failure in clients that enforce OCSP stapling.
Security implications
The handshake-failure metric is a security signal as well as a reliability signal. Three dimensions:
- It surfaces revocation in real time. A certificate that is revoked (compromised private key, CA mis-issuance) will fail the handshake for clients that enforce OCSP. The metric is the operator’s only visible signal that revocation has taken effect.
- It surfaces downgrade attacks. An active attacker intercepting TLS 1.3 traffic can force a downgrade to TLS 1.2 by tampering with the ClientHello. The handshake fails because the certificate is bound to a TLS 1.3-only cipher. The metric catches the attempt.
- It does not require storing cert data. The probe observes
the handshake; it does not capture the certificate or its
chain into the time series. The only persistent data is the
pass/fail outcome and the certificate’s
notAfter.
The trust store on the blackbox host is part of the security boundary. An attacker who can modify it can suppress handshake failures (by adding their own CA to the trust store). Treat the trust store like any other system configuration: version controlled, change-logged, integrity-checked.
Performance implications
Handshake probes are more expensive than expiry probes because they include the full handshake (not just reading metadata). A modern TLS 1.3 handshake with session resumption is 1-2 RTTs, roughly 50-150 ms. Without session resumption (the default for blackbox), it is 2-3 RTTs, roughly 100-300 ms.
At 1m scrape interval and 100 targets:
- ~100 handshakes per minute, ~1.7 per second sustained.
- Blackbox CPU: ~1-2 percent of one core.
- Network: negligible.
At 30s scrape interval (which is not recommended) and 1000 targets:
- ~2000 handshakes per minute, ~33 per second sustained.
- Blackbox CPU: ~10 percent of one core.
- Network: the volume becomes a small but non-zero contributor to upstream bandwidth.
The probe is cheap enough that the right tuning question is not “can the probe afford to run” but “is the probe missing something.” A common mistake is to disable the handshake probe to save CPU and rely on the expiry probe; the result is that chain and SAN failures go silent.
How to roll this back
Rolling back the handshake probe is the same pattern as the expiry probe (lesson 01).
- Remove the scrape job from
prometheus.yml. promtool check config /etc/prometheus/prometheus.yml.- Reload Prometheus.
- Remove the alert rule from the rules directory.
promtool check rulesto confirm the rule file is still valid (or remove the file if it was the only rule).- Optionally remove the blackbox module from
config.ymlif it is no longer used. - Verify with
up{job="blackbox_tls_handshake"}— should be empty.
No certificate or application state is touched. The probe is purely observational.
Verification
You should now be able to answer:
- What three failure shapes account for most TLS handshake failures in production?
- How does
probe_faileddiffer fromprobe_success, and why are both useful? - What
openssl s_clientverify return code indicates each of the three failure shapes? - Why does the handshake probe set
insecure_skip_verify: falsewhen the expiry probe sets it totrue? - How do you distinguish a chain failure from a SAN failure
from an expiry failure using
openssl?
Quiz
Knowledge check · 8 questions
Q1. Which three failure shapes account for the majority of TLS handshake failures in production?
Q2. The handshake-failure probe module should set insecure_skip_verify: false so that probe_failed fires on chain and SAN problems.
Q3. OpenSSL returns verify return code 62 from s_client. What failure shape is this?
Q4. Which of these are valid OpenSSL verify return codes and their meanings?
Q5. Which openssl s_client flag forces the client to present a specific SNI, and why is it necessary when reproducing a handshake failure?
Q6. The handshake probe reports failure on every target, but openssl s_client from the operator workstation reports success. What is the first thing to check?
Q7. A server can present a different certificate per SNI, so the probe must present the correct SNI to validate the chain against the right cert.
Q8. Which of these are common production causes of a probe_failed firing while real clients do not see a handshake failure?
Passing score: 75%. Answers are checked in this browser.