Skip to main content
RunBook Academy

ObservabilityLXIV · TLS MonitoringTLSMonitoring

TLS Protocol Health

Intermediate⏱ ~22 minbashopenssl

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

Not yet marked complete on this device.

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:

  1. 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.”
  2. Default config inheritance. nginx defaults to ssl_protocols TLSv1 TLSv1.1 TLSv1.2 on older distributions; Apache defaults to SSLProtocol all (which means “all compiled in, including 1.0”). Operators who never touched the directive are exposing TLS 1.0 by default.
  3. 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.

  1. 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_protocols are vulnerable. Symptom: testssl.sh reports TLS 1 offered (deprecated).

  2. The reload did not apply to all listeners. A default_server block in nginx, or a second bind directive 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.

  3. The cipher list is restrictive but the protocol list is not. Many configs have a carefully chosen ssl_ciphers list but a permissive ssl_protocols. Symptom: the TLSServerSupportsWeakCipher alert does not fire, but TLSServerSupportsWeakProtocol does.

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

  5. 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_info reports “TLS 1.3” while some clients negotiate TLS 1.2. Less common in TLS 1.3, but possible during the deprecation window.

  6. 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 MinProtocol to TLSv1 are 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”:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

  1. Revert the ssl_protocols and ssl_ciphers directives in the server config to the previous values.
  2. Validate the config (nginx -t, haproxy -c -f ...).
  3. Reload the service (nginx -s reload, systemctl reload haproxy).
  4. Re-run testssl.sh and confirm the deprecated versions and ciphers are accepted again.
  5. 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_info report, and how does it differ from testssl.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

  1. Q1. Which RFC formally deprecates TLS 1.0 and TLS 1.1?

  2. Q2. A modern browser confirming a successful TLS handshake is sufficient evidence that the server does not accept TLS 1.0.

  3. Q3. What does probe_tls_version_info report?

  4. Q4. Which cipher categories should be excluded from a production TLS 1.2 cipher list?

  5. Q5. Name two tools that enumerate the full TLS protocol and cipher inventory of a server.

  6. 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?

  7. Q7. TLS 1.3 uses 0-RTT resumption by default, which improves performance at the cost of a small replay-attack window.

  8. Q8. Which of these are valid nginx ssl_protocols directives for a modern production server?

Passing score: 75%. Answers are checked in this browser.