ObservabilityLXIV · TLS MonitoringTLSMonitoring
TLS Protocol Health
What you'll learn
- Identify the TLS protocol versions and cipher suites that production servers should support
- Read probe_tls_version_info to confirm the negotiated version and cipher
- Use testssl.sh or sslscan to enumerate the full protocol and cipher inventory
- Disable TLS 1.0 and TLS 1.1 on common production servers (nginx, HAProxy, Envoy) without breaking clients
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 pentester’s report landed in the security team’s queue. Three
production endpoints were accepting TLS 1.0 connections. The
ticket said “TLS 1.0 is deprecated; remediate by end of
quarter.” The application team opened nginx, saw a ssl_protocols
directive that listed TLSv1 TLSv1.1 TLSv1.2, and changed it to
TLSv1.2 TLSv1.3. They reloaded nginx. They verified with a
browser. They closed the ticket.
Two months later, a quarterly scan caught the same endpoints
still accepting TLS 1.0. The browser test had succeeded because
the browser only offered TLS 1.2+; nginx happily served 1.0 to
clients that asked. The reload had applied the new directive to
the worker processes, but a separate default_server block was
still bound to the old cipher set and listening on the same
port. The browser saw the correct server; the scan caught the
rogue one.
The lesson is that “we configured TLS 1.2+” is not the same as “we serve only TLS 1.2+.” The scan is the proof.
What it is
TLS protocol health is the property that a server negotiates only the protocol versions and cipher suites that the organisation’s security policy allows. In 2026, that means:
- TLS 1.3 — current, mandatory forward secrecy, modern AEAD ciphers (AES-GCM, ChaCha20-Poly1305), 1-RTT handshake.
- TLS 1.2 — acceptable, with AEAD ciphers only. CBC-mode ciphers and RC4 are unacceptable.
- TLS 1.1 and TLS 1.0 — deprecated by RFC 8996 in March 2021. Major browsers and PCI DSS no longer accept them.
A production server that offers TLS 1.0 has three problems: it is exploitable (BEAST, POODLE on the protocol level), it fails compliance (PCI DSS v3.2 requires TLS 1.2+), and it advertises weakness to scanners. The scan that proves the server is clean is the only signal that matters.
The blackbox exporter emits
probe_tls_version_info
— a gauge with version and cipher labels describing the
handshake that the probe negotiated:
probe_tls_version_info{version="TLS 1.3",cipher="TLS_AES_256_GCM_SHA384"} 1
The value is always 1; the labels carry the information. This
is the simplest way to get “what version did we negotiate” into
Prometheus. For a full protocol and cipher inventory, the
operator reaches for testssl.sh or
sslscan, both of which
enumerate every cipher the server is willing to negotiate.
Why a sysadmin cares
Protocol health is the security baseline. The expiry metric (lesson 01) and the handshake metric (lesson 03) protect availability. Protocol health protects the integrity of the session: a server that negotiates TLS 1.0 is one client config away from being exploited. The cost of a breach is several orders of magnitude higher than the cost of a 30-day cert renewal sprint.
The operational reality is harder than the policy suggests. Three reasons production servers still serve TLS 1.0:
- Legacy clients. Embedded devices, old mobile apps, and industrial control systems often speak only TLS 1.0. Disabling it breaks them. The compromise is usually “separate listener on a legacy port” rather than “disable outright.”
- Default config inheritance. nginx defaults to
ssl_protocols TLSv1 TLSv1.1 TLSv1.2on older distributions; Apache defaults toSSLProtocol all(which means “all compiled in, including 1.0”). Operators who never touched the directive are exposing TLS 1.0 by default. - Inheritance from shared config. A central reverse-proxy config is inherited by every vhost. One vhost that needs legacy TLS drags every other vhost down with it.
The scan is the only signal that catches all three.
How it works
The TLS handshake negotiates a version and a cipher suite.
Version negotiation in TLS 1.2 was client-driven: the client
sent its highest supported version in ClientHello, and the
server picked. TLS 1.3 changes this; the client advertises
supported versions in a supported_versions extension, and
the server picks. The cipher suite negotiation is also
different (TLS 1.3 separates the key exchange from the bulk
cipher).
TLS 1.2 ClientHello TLS 1.3 ClientHello
+--------------------------+ +--------------------------+
| client_version: 1.2 | | client_version: 1.2 |
| random | | random |
| session_id | | session_id |
| cipher_suites: | | cipher_suites: |
| TLS_ECDHE_ECDSA_AES... | | TLS_AES_256_GCM_SHA384 |
| TLS_ECDHE_RSA_AES... | | TLS_CHACHA20_POLY1305 |
| ... | | ... |
| extensions: | | extensions: |
| signature_algorithms | | supported_versions: |
| ... | | TLS 1.3, TLS 1.2 |
+--------------------------+ | signature_algorithms |
| key_share |
+--------------------------+
server picks highest common server picks highest common
supported version version in supported_versions
extension
The version the server picks is the version the client and
server use. The cipher suite is picked from the client’s list
intersected with the server’s list. If the intersection is
empty, the server sends TLS Alert: handshake_failure.
The scan enumerates the intersection: it sends a ClientHello for each version in turn, then for each cipher in each version, and records what the server accepts. The result is a list of “(version, cipher) pairs the server will negotiate.” This list is the inventory that policy audits against.
Under the hood
How to configure it
Three pieces: a blackbox module that emits the version metric, a cron-driven testssl.sh scan, and the alerts that watch for weakness.
The blackbox probe
The probe module from lesson 03 already emits
probe_tls_version_info because every successful handshake
populates it. To make it useful for protocol-health alerting,
add a recording rule that flattens the version label into a
boolean per instance:
groups:
- name: tls_protocol_health
interval: 5m
rules:
- record: tls_negotiated_version
expr: probe_tls_version_info
- record: tls_version_is_modern
expr: >
count by (instance) (
probe_tls_version_info{version=~"TLS 1.[23]"}
) > 0
The recording rule tls_version_is_modern is 1 if the probe
has successfully negotiated TLS 1.2 or TLS 1.3 on this instance
in the last evaluation window, and absent otherwise. It is
what the alert reads.
The testssl.sh scan
Run testssl.sh nightly per target.
Output as JSON for ingestion:
#!/usr/bin/env bash
# /usr/local/bin/tls-scan.sh
set -euo pipefail
TARGETS=(
"api.example.com"
"checkout.example.com"
"portal.example.com"
)
for t in "${TARGETS[@]}"; do
/opt/testssl.sh/testssl.sh \
--jsonfile-pretty "/var/log/tls-scan/${t}-$(date -u +%Y%m%dT%H%M%SZ).json" \
--color 0 \
--quiet \
"${t}:443"
done
The JSON output is verbose; pipe it to Loki via Promtail or
Alloy. The fields of interest are under
scanResult.each.protocols (a list of supported versions) and
scanResult.each.ciphers (a list of accepted cipher suites).
The alert
groups:
- name: tls_protocol_alerts
interval: 5m
rules:
- alert: TLSServerSupportsWeakProtocol
expr: >
count by (instance) (
testssl_protocols_supported{protocol=~"TLS 1\\.[01]"}
) > 0
for: 24h
labels:
severity: warning
category: tls
annotations:
summary: '{{ $labels.instance }} accepts {{ $value }} deprecated TLS protocol versions'
description: 'TLS 1.0 and 1.1 are deprecated by RFC 8996. Disable on the server and re-scan. Runbook: https://runbooks.example.com/tls/disable-legacy-protocols.'
runbook_url: 'https://runbooks.example.com/tls/disable-legacy-protocols'
- alert: TLSServerSupportsWeakCipher
expr: >
count by (instance) (
testssl_ciphers_accepted{cipher=~"CBC|RC4|NULL|EXPORT|3DES"}
) > 0
for: 24h
labels:
severity: warning
category: tls
annotations:
summary: '{{ $labels.instance }} accepts weak cipher suites'
description: 'CBC, RC4, NULL, EXPORT, and 3DES ciphers are weak. Restrict the ssl_ciphers directive to AEAD ciphers. Runbook: https://runbooks.example.com/tls/disable-legacy-protocols.'
runbook_url: 'https://runbooks.example.com/tls/disable-legacy-protocols'
The for: 24h is appropriate because the testssl scan runs
nightly; we do not want to alert on a single transient scan
result.
Server-side configuration
The server configuration that disables TLS 1.0 and TLS 1.1 on common production reverse proxies.
nginx (/etc/nginx/conf.d/ssl.conf):
# TLS 1.2 and 1.3 only.
ssl_protocols TLSv1.2 TLSv1.3;
# Mozilla "intermediate" cipher list, modern subset.
# AEAD ciphers only; no CBC, no RC4, no 3DES.
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305';
# Prefer the server's choice (server-side ordering).
ssl_prefer_server_ciphers on;
# Session tickets off for forward secrecy.
ssl_session_tickets off;
# Stapled OCSP for revocation checking.
ssl_stapling on;
ssl_stapling_verify on;
HAProxy (/etc/haproxy/haproxy.cfg):
frontend https
bind *:443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1
# ssl-default-bind-options controls the bind-level cipher set.
ssl-default-bind-options ssl-min-ver TLSv1.2 ssl-max-ver TLSv1.3 no-tls-tickets
ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305
Envoy (/etc/envoy/envoy.yaml):
transport_socket:
name: envoy.transport_sockets.tls
typed_config:
common_tls_context:
tls_params:
tls_minimum_protocol_version: TLSv1_2
tls_maximum_protocol_version: TLSv1_3
tls_certificate_sds_secret_configs:
- name: server_cert
sds_config:
path: /etc/envoy/certs.yaml
cipher suites:
- ECDHE-ECDSA-AES128-GCM-SHA256
- ECDHE-RSA-AES128-GCM-SHA256
- ECDHE-ECDSA-AES256-GCM-SHA384
- ECDHE-RSA-AES256-GCM-SHA384
- ECDHE-ECDSA-CHACHA20-POLY1305
- ECDHE-RSA-CHACHA20-POLY1305
In all three cases, the change is CONFIGURATION severity: a
reload is required, but no restart. After reload, re-run
testssl.sh and confirm the output no longer lists TLS 1.0 or
1.1.
How to validate it
Three checks: a fast check from the operator’s workstation, a full scan with testssl.sh, and a Prometheus query.
From the workstation (a single version check):
openssl s_client -connect api.example.com:443 -tls1_1 2>&1 | grep -E 'protocol|verify|alert'
If TLS 1.1 is disabled, the server sends an alert:
CONNECTED(00000005)
140735...:error:1409442E:SSL routines:ssl3_read_bytes:tlsv1 alert protocol version:ssl/record/rec_layer_srv.c:...:
---
no peer certificate available
---
No client certificate CA names sent
---
SSL handshake has read 7 bytes and written 0 bytes
The tlsv1 alert protocol version is the response. The
handshake fails at the version negotiation step. If TLS 1.1
is enabled, the same command shows a successful handshake with
Protocol : TLSv1.1.
Full scan with testssl.sh:
/opt/testssl.sh/testssl.sh --color 0 api.example.com:443 | head -80
Realistic output (healthy server):
Testing protocols via sockets except SPDY+HTTP2
SSLv2 not offered (OK)
SSLv3 not offered (OK)
TLS 1 not offered (OK)
TLS 1.1 not offered (OK)
TLS 1.2 offered (OK)
TLS 1.3 offered (OK): final
Realistic output (server still serving TLS 1.0):
TLS 1 offered (deprecated)
TLS 1.1 offered (deprecated)
TLS 1.2 offered (OK)
TLS 1.3 offered (OK): final
The “deprecated” annotation is testssl.sh’s signal that the version is below the security policy baseline.
Prometheus query:
tls_version_is_modern
Should be 1 for every probed instance. A 0 (or absent
value) means the probe did not negotiate a modern version in
the last window.
The full inventory (from the testssl Loki stream):
{job="tls-scan"} |= "TLS 1" | logfmt | protocol=~"TLS 1\\.[01]"
This returns every scan log entry mentioning a deprecated protocol. The Loki stream is the long-term record; Prometheus is the alert.
How it can fail
Six failure modes appear in production.
-
The default config still allows TLS 1.0. nginx and Apache on older distributions default to accepting TLS 1.0. Operators who never set
ssl_protocolsare vulnerable. Symptom: testssl.sh reportsTLS 1 offered (deprecated). -
The reload did not apply to all listeners. A
default_serverblock in nginx, or a secondbinddirective in HAProxy, may use a different SSL config. The reload applies the new config to the workers, but the listener config is sticky until the process restarts. Symptom: testssl.sh against the listening IP returns a different config than against the hostname. -
The cipher list is restrictive but the protocol list is not. Many configs have a carefully chosen
ssl_cipherslist but a permissivessl_protocols. Symptom: theTLSServerSupportsWeakCipheralert does not fire, butTLSServerSupportsWeakProtocoldoes. -
A reverse proxy in front of the application terminates TLS with a modern config, but the application server still serves TLS 1.0 on a separate port. The testssl scan against the proxy reports modern TLS. The scan against the application port reports TLS 1.0. Symptom: the policy audit passes; the application is still vulnerable. Solution: scan every TLS endpoint, not just the front door.
-
The probe target is on a load balancer that uses session resumption. The probe sees TLS 1.3 (the LB’s preferred version), but a subset of clients see TLS 1.2 (because the LB’s session cache returns a TLS 1.2 ticket). Symptom:
probe_tls_version_inforeports “TLS 1.3” while some clients negotiate TLS 1.2. Less common in TLS 1.3, but possible during the deprecation window. -
A library upgrade silently re-enabled weak protocols. OpenSSL 1.1.1 disabled SSLv3 by default but kept TLS 1.0 enabled. OpenSSL 3.0 disabled TLS 1.0 and 1.1 by default for new TLS contexts, but existing configs that set
MinProtocoltoTLSv1are unchanged. Symptom: an upgrade breaks nothing locally but the testssl scan against production shows TLS 1.0 is back.
How to troubleshoot it
The diagnostic order for “the scan says we’re still serving TLS 1.0”:
- Confirm the scan target is the production endpoint.
dig +short api.example.com— confirm the IP is the production LB. The scan may be hitting a stale DNS record. - Run the scan from the same source IP as production clients. If you scan from a CDN edge or a regional office, the result may differ from what a customer in a different geography sees.
- Inspect the running config.
nginx -T 2>/dev/null | grep ssl_protocols(nginx),haproxy -c -f /etc/haproxy/haproxy.cfg(HAProxy). Confirm the on-disk config matches what you expect. - Inspect the worker process.
ps aux | grep nginx— confirm the workers started after the most recent reload. A pending reload that has not yet been processed leaves workers running the old config. - Inspect the listener config.
ss -tlnp | grep :443— confirm the listening process is the one you reloaded. A rogue process (a stray nginx from a misconfigured systemd unit) may be bound to the port with the old config. - Re-run the scan. After each step, re-run testssl.sh and confirm the deprecated version disappears. If it persists, the problem is upstream of the server config (a CDN, a sidecar proxy, a separate listener).
Security implications
Protocol health is the core of TLS security. Three dimensions:
- Confidentiality. TLS 1.0 and 1.1 use CBC-mode ciphers that are vulnerable to BEAST and similar attacks. TLS 1.3 uses only AEAD ciphers (AES-GCM, ChaCha20-Poly1305), which are not vulnerable to the same class of attacks.
- Forward secrecy. TLS 1.2 with ECDHE key exchange provides forward secrecy; with RSA key exchange, it does not. TLS 1.3 removes RSA key exchange entirely. The cipher list must prefer ECDHE.
- Compliance. PCI DSS v3.2 (and later) requires TLS 1.2+. Most regulatory frameworks (HIPAA, GDPR for security baseline, SOC 2) require “modern TLS” without specifying a version. The combination of TLS 1.2+ and AEAD-only ciphers meets the spirit of all of them.
The scan output is itself sensitive: it reveals the protocol versions and cipher suites the server supports, which an attacker can use to identify legacy clients and target exploits. Treat scan output like any other system fingerprint: do not publish it externally; restrict access to the security team and the platform team.
Performance implications
TLS 1.3 is meaningfully faster than TLS 1.2 on the first connection (1-RTT vs 2-RTT handshake). On resumed connections, the difference is small (both are 0-RTT with session tickets, or 1-RTT with session IDs). For a high-traffic API:
- TLS 1.2 cold handshake: ~3 RTTs to first byte.
- TLS 1.3 cold handshake: ~1 RTT to first byte.
- TLS 1.3 with 0-RTT resumption: 0 RTTs to first byte, with a replay-vulnerability trade-off (the lesson on incident response discusses 0-RTT in production).
The cipher choice also matters. AES-GCM benefits from hardware acceleration (AES-NI on x86). ChaCha20-Poly1305 is faster on ARM and on x86 without AES-NI. A reasonable default is to prefer AES-GCM for browsers (which usually have AES-NI) and prefer ChaCha20-Poly1305 for low-power clients.
Protocol-health scans (testssl.sh) are expensive because they open hundreds of connections. A full scan of one target takes 30-90 seconds. With 100 targets scanned nightly, this is ~2 hours of scan time, runnable from a single host. Parallelise across multiple hosts if the inventory is larger.
How to roll this back
Rolling back a TLS protocol change is a server reload, not a destructive operation.
- Revert the
ssl_protocolsandssl_ciphersdirectives in the server config to the previous values. - Validate the config (
nginx -t,haproxy -c -f ...). - Reload the service (
nginx -s reload,systemctl reload haproxy). - Re-run testssl.sh and confirm the deprecated versions and ciphers are accepted again.
- Disable the corresponding alert rules in Prometheus if the rollback is permanent; otherwise leave them active so the alert re-fires when the next scan finds the weakness.
The rollback does not affect the certificate or the chain. It is a pure protocol-and-cipher configuration change.
Verification
You should now be able to answer:
- What does
probe_tls_version_inforeport, and how does it differ fromtestssl.sh’s output? - Which TLS protocol versions are deprecated by RFC 8996, and what is the appropriate response from a server?
- Why is the browser test insufficient for verifying TLS 1.0 is disabled?
- How do you configure nginx, HAProxy, and Envoy to accept only TLS 1.2 and TLS 1.3?
- Why must the testssl scan run against every TLS endpoint, not just the front-door reverse proxy?
Quiz
Knowledge check · 8 questions
Q1. Which RFC formally deprecates TLS 1.0 and TLS 1.1?
Q2. A modern browser confirming a successful TLS handshake is sufficient evidence that the server does not accept TLS 1.0.
Q3. What does probe_tls_version_info report?
Q4. Which cipher categories should be excluded from a production TLS 1.2 cipher list?
Q5. Name two tools that enumerate the full TLS protocol and cipher inventory of a server.
Q6. You disable TLS 1.0 in nginx and reload. The browser test passes, but testssl.sh still reports TLS 1.0 offered. What is the most likely cause?
Q7. TLS 1.3 uses 0-RTT resumption by default, which improves performance at the cost of a small replay-attack window.
Q8. Which of these are valid nginx ssl_protocols directives for a modern production server?
Passing score: 75%. Answers are checked in this browser.