Cloud security engineer testing OpenSSL hostname verification and TLS certificate validation at a workstation

Make Your OpenSSL Probe Fail on the Wrong Server Name

Fri, Sep 25, 2026

A TLS deployment check can look successful while proving the wrong thing. openssl s_client may establish a TLS session, print a certificate, report verification diagnostics, and still return a process status that a wrapper treats as success. The acceptance question is narrower and harder: when the server presents a certificate that is not valid for the intended DNS identity api.lab.test, does the probe itself reject that endpoint with a process verdict suitable for a release gate?

For OpenSSL 3.5, the answer is: not unless the probe is configured to verify that hostname and to return verification errors instead of continuing diagnostically. The OpenSSL 3.5 openssl-s_client documentation explicitly describes s_client as a diagnostic tool; it says verification normally continues after errors unless -verify_return_error is used, while -servername sets SNI rather than the hostname-verification predicate. It also states that -showcerts displays what the server sent, not a verified chain.

This playbook qualifies that negative path with owned loopback fixtures only. The commissioning experiment for batch 2026-09-25-batch2 used synthetic DNS names, private throwaway CAs, 127.0.0.1:8443, no public certificates, no credentials, and no system trust-store modification. The documentation baseline is intentionally fixed at OpenSSL 3.5 and was accessed September 25, 2026; it is not a claim that those pages identify some different “latest” release.

The laboratory described below was also reproduced for this article. Where exact exit statuses, fingerprints or diagnostics are stated as observed, they belong only to the recorded build and fixtures. The acceptance method is portable; those literal values are not universal constants.

Define the identity claim the probe must enforce

The contract is simple enough to write before touching OpenSSL:

Ledger field

Declared value

Intended URL identity

api.lab.test

Connection address

127.0.0.1:8443

TLS SNI name

api.lab.test

Verification hostname

api.lab.test

Accepted trust anchor

lab root.pem only

Positive certificate

SAN DNS:api.lab.test, signed by root.pem

Wrong-name control

SAN DNS:other.lab.test, signed by the same trusted root

Untrusted control

SAN DNS:api.lab.test, signed by different root2.pem

Transport control

nothing listening on 127.0.0.1:8443

The release claim is therefore not “TLS worked.” It is:

The endpoint reached at the declared routing address presented a server certificate that chains to the declared trust root and is valid for the declared DNS identity api.lab.test, and the qualified probe returned success only for that condition.

That distinction matters because several TLS properties are independent. TCP routing decides where packets go. SNI can influence which virtual-host certificate a server selects. Certificate path validation determines whether a chain reaches a trusted anchor. Hostname verification tests whether the authenticated certificate represents the intended DNS name. Application authorization happens later and is outside this laboratory.

OpenSSL documents these as distinct controls. -servername name places the supplied name in the ClientHello SNI extension; -verify_hostname hostname performs the certificate-name match. The verification-options documentation says that the latter checks the supplied hostname against DNS identifiers in the certificate.

Connecting to 127.0.0.1 therefore does not silently change this contract into an IP-identity check. The DNS identity remains api.lab.test. If the policy intended IP identity instead, that would be a different contract and OpenSSL provides -verify_ip for it.

This article complements Refonte Learning’s broader secure API foundations rather than repeating an API-security checklist. The concern here is evidence quality: can the particular diagnostic client used in a deployment workflow distinguish the valid server identity from controlled invalid identities?

The acceptance policy is deliberately asymmetric:

  • Accept only when the qualified probe passes the trusted, correct-name fixture and rejects every negative control for the expected class of reason.

  • Repair when the probe is missing a required verification field, the trust input is wrong, or the serving certificate is wrong.

  • Hold when evidence is ambiguous, for example, a harness timeout, stale listener, missing status file, unrecorded executable, or a diagnostic/implementation discrepancy.

  • Block an endpoint that is actually wrong under the declared identity policy, such as a trusted certificate whose SAN does not contain api.lab.test or a correct-name certificate whose issuer is not trusted.

A printed PEM block or a completed handshake does not meet this contract. OpenSSL states that -showcerts only displays the certificate list sent by the server and that the list is not a verified chain.

Pin the client, trust inputs and loopback environment

Release evidence is weak if it records only “OpenSSL passed.” Before generating a certificate, capture the executable, build, operating system, shell, trust file, identities and comparison-client backend.

OpenSSL 3.5’s openssl-version documentation says openssl version -a prints all available version information, including version, build date, build options, compilation flags, platform and installation directories; the documentation specifically notes that this full output is typically useful in bug reports.

A reproducible preflight can begin as follows:

#!/usr/bin/env bash
set -euo pipefail

BATCH='2026-09-25-batch2'
LAB="$PWD/tls-probe-${BATCH}"

# Refuse path collisions. Never merge this lab into an existing directory.
if [ -e "$LAB" ]; then
    printf 'Refusing existing path: %s\n' "$LAB" >&2
    exit 2
fi

umask 077
mkdir "$LAB"
cd "$LAB"

# Marker used later to constrain cleanup to this lab.
printf '%s\n' "$BATCH" > .lab-owner-marker

mkdir evidence

{
    printf 'commissioning_batch=%s\n' "$BATCH"
    printf 'openssl_path=%s\n' "$(command -v openssl)"
    openssl version -a

    printf 'curl_path=%s\n' "$(command -v curl)"
    curl --version

    printf 'shell_env=%s\n' "${SHELL:-unknown}"
    printf 'bash_version=%s\n' "${BASH_VERSION:-unknown}"

    printf 'os_release_begin\n'
    if [ -r /etc/os-release ]; then
        cat /etc/os-release
    fi
    printf 'os_release_end\n'
    uname -a

    printf 'routing_address=%s\n' '127.0.0.1:8443'
    printf 'sni_name=%s\n' 'api.lab.test'
    printf 'verification_hostname=%s\n' 'api.lab.test'
} > evidence/environment.txt

The commissioning reproduction used the following observed environment, which is useful because its OpenSSL binary actually belongs to the fixed 3.5 documentation line:

Recorded item

Observed value

OpenSSL executable

/usr/bin/openssl

OpenSSL

OpenSSL 3.5.5 27 Jan 2026

Library

OpenSSL 3.5.5 27 Jan 2026

Build time

Fri Apr 3 10:05:32 2026 UTC

Platform

debian-amd64

OpenSSL directory

/usr/lib/ssl

OS

Debian GNU/Linux 13.3, trixie

Kernel

Linux 6.18.44, x86_64

Shell

/bin/bash, Bash 5.2.37(1)-release

curl executable

/usr/local/bin/curl

curl

8.10.1

curl TLS backend

OpenSSL/3.5.5

Connection

127.0.0.1:8443

SNI

api.lab.test

Verification hostname

api.lab.test

Fixture commit

e5ea1564a8ea413db0089c85f017d0f25d253917

The exact compiler flags were also retained from openssl version -a; they included the Debian build’s hardening and optimization settings. Do not abbreviate that output in the archived evidence even if an article or ticket shows only the summary.

The fixture definitions and harness should be committed before the run, while generated keys, certificates and logs remain outside source control:

.key
.csr
*.pem
*.srl
evidence/
server.*
git init
git config user.email '[email protected]'
git config user.name 'TLS Probe Lab'
git add \
  .gitignore \
  root.cnf root2.cnf api.cnf other.cnf \
  api.ext other.ext response.txt \
  run_sclient.py env_capture.sh
git commit -m 'TLS probe fixture 2026-09-25-batch2'

git rev-parse HEAD >> evidence/fixture-commit.txt

Once root.pem exists, record its path and digest rather than assuming a file named root.pem is the same trust input between runs:

{
    printf 'trust_file=%s\n' "$PWD/root.pem"
    printf 'trust_file_sha256='
    sha256sum root.pem | awk '{print $1}'
} >> evidence/environment.txt

The reproduction’s observed SHA-256 digest of the trusted root file was:

d55ff3c5c0ed3e2ce50169fba6d2798d81f8b6cde3513d9be877eda3ac7d8b80

That value is not an expected constant for readers: newly generated keys necessarily produce different certificates and fingerprints.

These environmental fields are part of the probe’s specification, not operational trivia. A wrapper can behave differently after an OpenSSL package update, a curl backend change, a trust-file replacement or a shell-pipeline modification. This narrow concern is compatible with broader API engineering practices, but it does not turn the laboratory into an API-tooling survey.

Create valid and deliberately invalid certificate fixtures

The laboratory needs two private roots and three server leaves:

  1. root.pem: trusted throwaway CA.

  2. api.pem: trusted leaf, SAN api.lab.test.

  3. other.pem: trusted leaf, SAN other.lab.test.

  4. root2.pem: second throwaway CA that the client will not trust.

  5. api-untrusted.pem: SAN api.lab.test, signed by root2.pem.

The root configuration is explicit:

# root.cnf
[ req ]
prompt = no
distinguished_name = dn
x509_extensions = v3_ca

[ dn ]
O = Refonte Learning TLS Probe Lab
CN = Refonte TLS Probe Lab Root

[ v3_ca ]
basicConstraints = critical, CA:TRUE, pathlen:0
keyUsage = critical, keyCertSign, cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always, issuer

The second root differs only in identity:

# root2.cnf
[ req ]
prompt = no
distinguished_name = dn
x509_extensions = v3_ca

[ dn ]
O = Refonte Learning TLS Probe Lab
CN = Refonte TLS Probe Untrusted Root

[ v3_ca ]
basicConstraints = critical, CA:TRUE, pathlen:0
keyUsage = critical, keyCertSign, cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always, issuer

Use separate request files for the two leaf subjects:

# api.cnf
[ req ]
prompt = no
distinguished_name = dn

[ dn ]
O = Refonte Learning TLS Probe Lab
CN = api.lab.test
# other.cnf
[ req ]
prompt = no
distinguished_name = dn

[ dn ]
O = Refonte Learning TLS Probe Lab
CN = other.lab.test

Most importantly, put the server extensions in the signing configuration, not merely in the CSR and not behind an assumption that openssl x509 will copy them. OpenSSL 3.5’s openssl-x509 documentation states that extensions in a CSR are not copied by default and documents -extfile plus -extensions for extensions that should be added to the issued certificate.

For the correct server identity:

# api.ext
[ server_cert ]
basicConstraints = critical, CA:FALSE
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid, issuer

[ alt_names ]
DNS.1 = api.lab.test

For the wrong-name control:

# other.ext
[ server_cert ]
basicConstraints = critical, CA:FALSE
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid, issuer

[ alt_names ]
DNS.1 = other.lab.test

The OpenSSL 3.5 x509v3_config documentation defines CA:FALSE for an end-entity basic-constraints extension, lists digitalSignature and keyEncipherment among key-usage values, identifies serverAuth as the TLS server-authentication extended key usage, and documents the DNS: form of subjectAltName.

Generate everything under the restrictive umask established earlier:

# Trusted root: 30-day throwaway lab validity.
openssl genpkey \
  -algorithm RSA \
  -pkeyopt rsa_keygen_bits:2048 \
  -out root.key

openssl req \
  -x509 \
  -new \
  -key root.key \
  -sha256 \
  -days 30 \
  -config root.cnf \
  -out root.pem

# Deliberately untrusted root.
openssl genpkey \
  -algorithm RSA \
  -pkeyopt rsa_keygen_bits:2048 \
  -out root2.key

openssl req \
  -x509 \
  -new \
  -key root2.key \
  -sha256 \
  -days 30 \
  -config root2.cnf \
  -out root2.pem

# Trusted, correct-name leaf.
openssl genpkey \
  -algorithm RSA \
  -pkeyopt rsa_keygen_bits:2048 \
  -out api.key

openssl req \
  -new \
  -key api.key \
  -config api.cnf \
  -out api.csr

openssl x509 \
  -req \
  -in api.csr \
  -CA root.pem \
  -CAkey root.key \
  -days 7 \
  -sha256 \
  -extfile api.ext \
  -extensions server_cert \
  -out api.pem

# Trusted, wrong-name leaf.
openssl genpkey \
  -algorithm RSA \
  -pkeyopt rsa_keygen_bits:2048 \
  -out other.key

openssl req \
  -new \
  -key other.key \
  -config other.cnf \
  -out other.csr

openssl x509 \
  -req \
  -in other.csr \
  -CA root.pem \
  -CAkey root.key \
  -days 7 \
  -sha256 \
  -extfile other.ext \
  -extensions server_cert \
  -out other.pem

# Correct-name leaf from a different, untrusted root.
openssl genpkey \
  -algorithm RSA \
  -pkeyopt rsa_keygen_bits:2048 \
  -out api-untrusted.key

openssl req \
  -new \
  -key api-untrusted.key \
  -config api.cnf \
  -out api-untrusted.csr

openssl x509 \
  -req \
  -in api-untrusted.csr \
  -CA root2.pem \
  -CAkey root2.key \
  -days 7 \
  -sha256 \
  -extfile api.ext \
  -extensions server_cert \
  -out api-untrusted.pem

chmod 600 root.key root2.key api.key other.key api-untrusted.key

The seven-day leaf and 30-day root periods are merely documented lab choices to keep throwaway material short-lived. They imply nothing about public-CA lifetime requirements.

OpenSSL’s openssl-req documentation likewise documents explicit request configuration and extension handling, including -addext; the laboratory uses explicit files so the generated fixture is reviewable and not dependent on hidden global configuration.

Inspect each signed certificate before serving it:

for cert in root.pem root2.pem api.pem other.pem api-untrusted.pem; do
    printf '\n=== %s ===\n' "$cert"

    openssl x509 \
      -in "$cert" \
      -noout \
      -sha256 \
      -fingerprint \
      -subject \
      -issuer \
      -dates

    openssl x509 \
      -in "$cert" \
      -noout \
      -ext subjectAltName 2>/dev/null || true

    openssl x509 \
      -in "$cert" \
      -noout \
      -ext basicConstraints \
      -ext keyUsage \
      -ext extendedKeyUsage 2>/dev/null || true
done

The commissioning run observed these three leaf fingerprints:

Fixture

SAN

Issuer

Observed SHA-256 leaf fingerprint

Correct trusted

api.lab.test

trusted lab root

61:62:2C:72:42:B2:43:01:DD:52:11:63:38:E1:F4:74:D0:EA:B1:1B:9F:D1:F1:AF:8E:33:51:D9:31:AE:5D:9F

Wrong-name trusted

other.lab.test

trusted lab root

67:18:23:F9:18:1A:56:FB:8D:A4:A9:2A:78:F3:7A:E9:8B:04:4F:FD:2B:05:5D:30:C1:6B:3F:14:68:E9:53:72

Correct-name untrusted

api.lab.test

untrusted lab root

32:C3:C8:3B:BA:5F:F0:4C:3A:87:7A:08:FA:50:6F:03:67:73:69:B6:F0:D3:2D:4C:A5:19:69:91:AB:4F:E0:C9

Freshly generated certificates should not reproduce those fingerprints. Their purpose is to tie this run’s server PID and verifier logs to the exact leaf that was presented.

Serve one known certificate at a time

Create one deterministic HTTP response. OpenSSL 3.5 documents s_server -HTTP as a simple HTTP server mode in which files are served relative to the process’s working directory and may contain their own HTTP response headers. It also documents -accept for the listening host and port and -cert/-key for the server identity.

printf \
'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 3\r\nConnection: close\r\n\r\nOK\n' \
> response.txt

Start only one fixture on loopback:

start_server() {
    label=$1
    cert=$2
    key=$3

    openssl s_server \
      -accept 127.0.0.1:8443 \
      -cert "$cert" \
      -key "$key" \
      -HTTP \
      >"evidence/server-${label}.stdout" \
      2>"evidence/server-${label}.stderr" &

    SERVER_PID=$!
    printf '%s\n' "$SERVER_PID" > "evidence/server-${label}.pid"

    # Linux readiness check: verify that this PID owns the loopback listener.
    for in $(seq 1 50); do
        if ss -ltnp 2>/dev/null |
           grep -F '127.0.0.1:8443' |
           grep -F "pid=$SERVERPID" >/dev/null; then
            break
        fi

        if ! kill -0 "$SERVER_PID" 2>/dev/null; then
            printf 'Server exited before readiness\n' >&2
            return 1
        fi

        sleep 0.05
    done

    ss -ltnp |
      grep -F '127.0.0.1:8443' \
      >"evidence/server-${label}.listener.txt"
}

Do not interpret a client failure until you prove that the intended certificate swap occurred. A stale s_server, another process already occupying 8443, or a server launched with the previous leaf invalidates the comparison even if the client log looks plausible.

A presentation-only inspection can capture the peer leaf independently of the gate:

printf \
'GET /response.txt HTTP/1.1\r\nHost: api.lab.test\r\nConnection: close\r\n\r\n' |
openssl s_client \
  -connect 127.0.0.1:8443 \
  -servername api.lab.test \
  -showcerts \
  > evidence/presentation.stdout \
  2> evidence/presentation.stderr

awk '
  /-----BEGIN CERTIFICATE-----/ { keep=1 }
  keep { print }
  /-----END CERTIFICATE-----/ { exit }
' evidence/presentation.stdout > evidence/presented.pem

openssl x509 \
  -in evidence/presented.pem \
  -noout \
  -sha256 \
  -fingerprint \
  -subject \
  -issuer \
  -ext subjectAltName \
  > evidence/presented.inventory.txt

This extraction is inventory evidence, not an acceptance predicate. -showcerts is not chain verification.

In the reproduced run, the wrong-name server PID was 377, the correct-name trusted server PID was 415, and the untrusted-root server PID was 439. For each server, the fingerprint extracted from the live TLS connection matched the pre-recorded leaf fingerprint above. That correspondence is what permits the later verifier result to be attributed to the intended fixture.

Stop each fixture cleanly before changing certificates:

stop_server() {
    label=$1
    pid=$(cat "evidence/server-${label}.pid")

    if kill -0 "$pid" 2>/dev/null; then
        kill -TERM "$pid"
        wait "$pid" || true
    fi

    if kill -0 "$pid" 2>/dev/null; then
        printf 'Lab server PID %s still alive\n' "$pid" >&2
        return 1
    fi
}

Do not run pkill openssl, killall openssl, or any other broad cleanup that could affect unrelated processes.

The complete cycle for the trusted wrong-name control is:

start_server wrong other.pem other.key

# inspect live leaf, run probes, collect evidence here

stop_server wrong

Then repeat with api.pem/api.key, then api-untrusted.pem/api-untrusted.key. Hold the address, port, SNI and expected hostname constant. The certificate/trust relationship is the variable.

That is the core experimental design: 127.0.0.1:8443 does not move, api.lab.test does not move, and the verifier command does not silently mutate between matrix cells.

Run the diagnostic command without a strict verdict

Begin with the kind of command commonly used for troubleshooting:

openssl s_client \
  -connect 127.0.0.1:8443 \
  -servername api.lab.test \
  -showcerts

Each flag proves something narrower than a release gate needs.

-connect 127.0.0.1:8443 controls the network destination. It does not declare that the certificate should represent 127.0.0.1, nor does it perform the DNS-name policy required here.

-servername api.lab.test sends the SNI extension. According to the OpenSSL 3.5 s_client documentation, SNI is placed in the ClientHello with this option. That can cause a virtual-hosted server to choose the certificate associated with api.lab.test; it is not itself a certificate-name comparison.

-showcerts displays the certificates the server sent. OpenSSL explicitly warns that this list is not a verified chain.

The diagnostic client also has intentionally permissive error behavior. OpenSSL says that when verification is enabled without -verify_return_error, verification continues after errors so multiple certificate problems can be examined; it further notes that this means the connection does not fail because of a server-certificate verification failure. -verify_return_error, by contrast, causes verification errors to be returned and will typically abort the handshake.

The wrong-name certificate is therefore a valuable negative control. It is not advice to weaken production verification. Its purpose is to reveal whether the check contains a hostname predicate at all.

In the commissioning reproduction, the diagnostic command was run while the server presented other.pem, whose SAN was only DNS:other.lab.test.

The observed process status was 0.

Because the trusted lab root had not been supplied to this diagnostic command, its logs included:

verify error:num=20:unable to get local issuer certificate
verify error:num=21:unable to verify the first certificate

and later:

Verification error: unable to verify the first certificate
Verify return code: 21 (unable to verify the first certificate)

Yet the actual OpenSSL process exited successfully.

That is precisely why grepping the output for a scary-looking string is an inferior acceptance mechanism. A wrapper that ignores the subprocess status could fail open; a wrapper that treats any line containing CONNECTED as success could also fail open. Conversely, a wrapper that expects one exact numeric diagnostic could become brittle between builds.

Now add the trusted root but still omit hostname verification:

openssl s_client \
  -connect 127.0.0.1:8443 \
  -CAfile root.pem \
  -servername api.lab.test \
  -showcerts

This is the cleaner negative control, because chain trust is no longer the distraction. The server still presents other.lab.test, but its issuer is trusted.

The commissioning reproduction observed:

Verify return code: 0 (ok)

and an OpenSSL process exit status of 0.

That result answers a common ambiguity directly: supplying the correct CA file and SNI does not, by itself, enforce api.lab.test as the certificate identity. The command accepted the chain because the root was trusted, while no hostname predicate had been requested.

OpenSSL’s verification-options documentation identifies -CAfile as an input that loads trusted certificates and -verify_hostname as the separate hostname-matching option.

This result should trigger repair of the probe, not replacement of the wrong-name fixture. A checker that accepts both the positive certificate and the trusted wrong-name certificate is unqualified for a DNS-identity release gate.

Add explicit trust and hostname rejection

The strict form is:

openssl s_client \
  -connect 127.0.0.1:8443 \
  -CAfile root.pem \
  -servername api.lab.test \
  -verify_hostname api.lab.test \
  -verify_return_error \
  -showcerts

Each verification field should be reviewed independently.

-CAfile root.pem identifies the trust input. OpenSSL’s verification documentation says -CAfile loads the supplied certificate or certificates as trusted material. Do not replace this lab-local file by modifying the host’s system trust store; the experiment needs an explicit, digestible trust input.

-servername api.lab.test sends SNI. It controls server-side certificate selection where SNI is relevant. It is not the acceptance predicate.

-verify_hostname api.lab.test asks OpenSSL to verify the certificate against the intended DNS hostname. OpenSSL 3.5 documents it separately from SNI.

-verify_return_error changes the operational verdict: the s_client documentation says verification errors are returned rather than merely continued through, and this will typically abort the TLS handshake.

Against the trusted wrong-name leaf, the commissioning run observed an OpenSSL exit status of 1 and the relevant diagnostic:

verify error:num=62:hostname mismatch

followed by a certificate-verification failure. This exact numeric value is reported only because it was observed on /usr/bin/openssl OpenSSL 3.5.5 in this fixture. The gate policy should classify the failure semantically as a hostname-verification rejection; it should not assume that an undocumented wrapper around every future build must emit the same complete transcript.

Against the trusted correct-name leaf, the same strict command observed exit status 0 and:

Verify return code: 0 (ok)

The live leaf SAN was DNS:api.lab.test, its issuer was the trusted lab root, and its fingerprint matched the pre-run certificate inventory.

Against the correct-name certificate issued by root2.pem, the same strict command observed exit status 1. The name was right, but the declared trust input was deliberately root.pem. The diagnostic was an issuer/path-validation failure:

verify error:num=20:unable to get local issuer certificate

This third fixture prevents a shallow implementation from equating “name matches” with “certificate is accepted.” The probe must enforce both trust and identity.

An independent certificate verification command can sanity-check the fixtures before the network experiment:

openssl verify \
  -CAfile root.pem \
  -verify_hostname api.lab.test \
  api.pem

openssl verify \
  -CAfile root.pem \
  -verify_hostname api.lab.test \
  other.pem

openssl verify \
  -CAfile root.pem \
  -verify_hostname api.lab.test \
  api-untrusted.pem

In the commissioning run, the first returned success; the second failed for hostname mismatch; the third failed because the issuer was not available from the declared trusted root. Those checks establish that the fixtures have the intended static properties, but they do not replace the s_client network test because the release question includes what the server actually presents.

Process handling is just as important as flags. Do not write:

# WRONG FOR A GATE:
openssl s_client ... 2>&1 | tee probe.log | grep 'Verify return code: 0'
echo "$?"

Without deliberate shell handling, $? here represents the final pipeline command, not necessarily openssl. Even pipefail does not solve every evidence problem if the automation later converts the result to Boolean text or loses the exact child status.

Use a bounded subprocess harness with separate stdout, stderr and status. This Python harness also supplies a complete HTTP request:

#!/usr/bin/env python3

import json
import subprocess
import sys
from pathlib import Path

usage = "usage: run_sclient.py LABEL TIMEOUT_SECONDS -- COMMAND..."

try:
    , label, timeouttext, separator, *cmd = sys.argv
except ValueError:
    raise SystemExit(usage)

if separator != "--" or not cmd:
    raise SystemExit(usage)

timeout_s = float(timeout_text)

request = (
    b"GET /response.txt HTTP/1.1\r\n"
    b"Host: api.lab.test\r\n"
    b"Connection: close\r\n"
    b"\r\n"
)

evidence = Path("evidence")
evidence.mkdir(exist_ok=True)

proc = subprocess.Popen(
    cmd,
    stdin=subprocess.PIPE,
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
)

timed_out = False

try:
    stdout, stderr = proc.communicate(
        input=request,
        timeout=timeout_s,
    )
except subprocess.TimeoutExpired:
    timed_out = True
    proc.kill()
    stdout, stderr = proc.communicate()

record = {
    "label": label,
    "command": cmd,
    "timeout_seconds": timeout_s,
    "timed_out": timed_out,
    "openssl_returncode": proc.returncode,
}

(evidence / f"{label}.stdout").write_bytes(stdout)
(evidence / f"{label}.stderr").write_bytes(stderr)
(evidence / f"{label}.json").write_text(
    json.dumps(record, indent=2) + "\n",
    encoding="utf-8",
)

print(json.dumps(record, sort_keys=True))

if timed_out:
    raise SystemExit(124)

if proc.returncode < 0:
    raise SystemExit(125)

raise SystemExit(proc.returncode)

Invoke the strict probe through it:

python3 ./run_sclient.py strict-right 5 -- \
  "$(command -v openssl)" s_client \
  -connect 127.0.0.1:8443 \
  -CAfile root.pem \
  -servername api.lab.test \
  -verify_hostname api.lab.test \
  -verify_return_error \
  -showcerts \
  -ign_eof

-ign_eof is useful in this controlled HTTP exchange because it prevents stdin EOF from immediately initiating shutdown; the server’s Connection: close response can then determine the read lifecycle, while the outer five-second harness still bounds a peer that fails to close. OpenSSL documents -ign_eof as inhibiting shutdown when input reaches EOF.

The commissioning reproduction separately reran the positive fixture this way. It observed exit 0 and received the complete controlled response:

HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 3
Connection: close

OK

Do not use s_client -timeout as a generic TCP timeout. OpenSSL 3.5 explicitly documents s_client -timeout as enabling send/receive timeout on DTLS connections. The wall-clock limit above belongs to the outer harness.

A harness timeout and a hostname rejection both prevent a positive verdict, but they mean different things. A hostname rejection demonstrates that verification ran and rejected the identity. A harness timeout establishes only that the run did not complete within its bound. The latter is hold/failure of the probe run, not proof of hostname validation.

Reconcile the positive and negative matrix

The matrix must include all four controls. A checker that merely fails on everything is not qualified.

The commissioning results were:

Control

Presented identity/trust

Strict s_client

Observed process result

Interpretation

Trusted right name

SAN api.lab.test, trusted root.pem

Must accept

0

Positive control passed

Trusted wrong name

SAN other.lab.test, trusted root.pem

Must reject

1

Hostname rejection

Untrusted right name

SAN api.lab.test, signed by root2.pem

Must reject

1

Trust rejection

Transport unavailable

no listener

Must not pass

1

Connection failure, not certificate evidence

For the transport-unavailable control, the run first confirmed there was no listener on 127.0.0.1:8443. The s_client process then returned 1 with a connection-refused diagnostic. That result belongs in a different classification from either certificate negative. No TLS peer was reached, so no hostname comparison against a presented certificate could have been demonstrated.

The strict probe is qualified only if the pattern is:

PASS  trusted right name
FAIL  trusted wrong name
FAIL  untrusted right name
FAIL  transport unavailable

If the pattern is:

FAIL
FAIL
FAIL
FAIL

do not congratulate the gate for being “secure.” The positive fixture proves whether the checker can recognize the intended relationship. Rejecting everything may indicate a bad CA file, wrong path, expired fixture, broken server, unsupported option, stale listener, bad wrapper or unrelated transport failure.

An independent curl comparison is useful as supplementary evidence because curl’s default HTTPS verification model includes both CA validation and verification that the certificate is issued for the hostname in the URL. curl’s official TLS Certificate Verification documentation says curl verifies certificates by default using the CA trust relationship and the server name in the URL; it documents --cacert for a custom CA file and warns against disabling verification with -k/--insecure.

Keep api.lab.test in the URL and route it to loopback with --resolve:

curl -q \
  --noproxy '*' \
  --cacert "$PWD/root.pem" \
  --resolve api.lab.test:8443:127.0.0.1 \
  --connect-timeout 2 \
  --max-time 5 \
  --silent \
  --show-error \
  --verbose \
  https://api.lab.test:8443/response.txt

The official curl command-line manual documents --resolve host:port:addr as a way to supply an address mapping while retaining the hostname in the URL, and shows example.com being resolved to 127.0.0.1. It separately documents --connect-timeout as bounding the connection phase, including requested TCP/TLS handshakes, and --max-time as limiting each transfer.

--noproxy '*' keeps this loopback comparison out of an environment-configured proxy path. -q disables curl’s normal config-file loading, helping prevent an unnoticed .curlrc from modifying the experiment. The curl manual notes that curl normally searches for a default config file unless that behavior is disabled.

Do not replace the URL with https://127.0.0.1:8443/. That would change the reference identity from the required DNS name to an IP address. Do not add -k; curl explicitly identifies that option as disabling peer verification and recommends against using it in production.

The commissioning curl build was recorded as:

curl 8.10.1
libcurl/8.10.1
TLS backend: OpenSSL/3.5.5

The curl manual says --version reports the curl/libcurl version and linked third-party libraries, which is why that output belongs in the evidence package.

Observed curl results were:

Fixture

Observed curl exit

Relevant observed result

Trusted right name

0

SAN matched; certificate verification OK

Trusted wrong name

60

SAN did not match api.lab.test

Untrusted right name

60

unable to get local issuer certificate

Transport unavailable

7

could not connect

Those exact numbers belong to this recorded curl build; release policy should classify the semantics, not infer that all curl versions, TLS backends or other clients expose identical diagnostics. curl is supplementary evidence for the backend that was actually recorded. It does not prove OpenSSL CLI defaults, browser behavior or every application’s TLS policy.

This narrow comparison belongs beside broader API security and observability, but it should remain a probe-qualification exercise rather than another general security checklist.

When a field fails, repair that field first.

If the trusted wrong-name fixture passes, repair the probe:

# Before: insufficient for hostname-gating
openssl s_client \
  -connect 127.0.0.1:8443 \
  -CAfile root.pem \
  -servername api.lab.test

# After: explicit identity gate
openssl s_client \
  -connect 127.0.0.1:8443 \
  -CAfile root.pem \
  -servername api.lab.test \
  -verify_hostname api.lab.test \
  -verify_return_error

If the correct-name leaf is rejected for issuer/trust reasons, inspect the -CAfile path and its digest. Do not “repair” that problem by importing the CA system-wide for this experiment.

If the served leaf itself has other.lab.test when the endpoint contract says api.lab.test, repair the serving identity. The checker should continue rejecting the broken endpoint until the correct leaf is actually presented.

If connection routing is wrong, repair the routing/listener field; do not weaken verification to make the request succeed.

This same field-oriented debugging discipline is useful in secure database integration context: endpoint, credential/trust material and application identity are separate inputs and should remain separately observable.

After any repair, rerun the full matrix. Never preserve only the new positive result. The old failure is part of the audit trail; the negative controls demonstrate that the fix did not accidentally create a fail-open checker.

Package the release evidence around immutable or digestible artifacts:

batch
fixture commit
OpenSSL executable path
openssl version -a
OS and shell
connection address
SNI
verification hostname
CA file path
CA SHA-256 digest
presented leaf fingerprint
presented subject/issuer/SAN
server PID
full client argv
stdout file
stderr file
actual client process status
outer timeout flag/status
curl --version
curl comparison status
decision

A practical decision table is:

Evidence condition

Decision

Positive passes; wrong-name and untrusted controls reject for their intended reasons; transport control does not pass

Accept qualified probe

Wrong-name passes because hostname verification is missing

Repair probe settings

Endpoint presents wrong SAN under declared production identity

Block endpoint

Endpoint chain does not reach declared trust input

Block or repair serving/trust configuration according to ownership

Probe times out, stale PID is possible, certificate swap is unproven, status is lost, or docs/implementation conflict

Hold evidence

Wrapper accepts nonzero OpenSSL status or replaces it with grep/tee success

Repair wrapper and requalify

All certificate controls fail, including the valid positive

Hold/repair; probe is not qualified

Certificate owners and automation owners have different responsibilities. The certificate/service owner owns the SAN, issuer relationship and certificate actually served. The automation owner owns -CAfile, SNI, -verify_hostname, -verify_return_error, timeout handling, process-status preservation and evidence retention. A change to either side can invalidate prior qualification.

Changes to verification flags, OpenSSL executable/build, trust inputs, connection wrapper or parsing logic should therefore trigger requalification. Treat the checker itself as a controlled component. Operational evidence and monitoring are relevant here because the decision depends on preserving enough state to distinguish an identity failure from a transport or wrapper failure.

For teardown, archive only nonsecret evidence and public certificate material, then remove only the directory marked as lab-owned. Private keys must not enter the evidence archive.

# LAB-ONLY CLEANUP. Do not adapt this path check casually.
set -euo pipefail

BATCH='2026-09-25-batch2'
LAB="$PWD/tls-probe-${BATCH}"
ARCHIVE="$PWD/${BATCH}-nonsecret-evidence.tar.gz"

if [ ! -d "$LAB" ] ||
   [ ! -f "$LAB/.lab-owner-marker" ] ||
   [ "$(cat "$LAB/.lab-owner-marker")" != "$BATCH" ]; then
    printf 'Refusing cleanup: lab ownership marker failed\n' >&2
    exit 2
fi

# Stop only the PID recorded by this laboratory.
if [ -f "$LAB/evidence/server.pid" ]; then
    pid=$(cat "$LAB/evidence/server.pid")
    if kill -0 "$pid" 2>/dev/null; then
        args=$(ps -p "$pid" -o args= || true)
        case "$args" in
            "openssl s_server""$LAB"*)
                kill -TERM "$pid"
                wait "$pid" || true
                ;;
            *)
                printf 'Refusing to kill unexpected PID %s\n' "$pid" >&2
                exit 3
                ;;
        esac
    fi
fi

if [ -e "$ARCHIVE" ]; then
    printf 'Refusing existing archive: %s\n' "$ARCHIVE" >&2
    exit 4
fi

tar -C "$LAB" -czf "$ARCHIVE" \
  .lab-owner-marker \
  .gitignore \
  root.cnf root2.cnf api.cnf other.cnf \
  api.ext other.ext response.txt \
  root.pem root2.pem api.pem other.pem api-untrusted.pem \
  evidence

sha256sum "$ARCHIVE"

# Only after all ownership checks and archive creation succeed.
rm -rf -- "$LAB"

In the commissioning reproduction, the nonsecret archive was created only after confirming no listener remained on 127.0.0.1:8443; its observed SHA-256 digest was:

394f84ae3ff07ec0caf252cd551f446a41d1b59f7a9ca23559595c83f8b6a3dd

The disposable lab directory was then removed. That digest identifies this execution archive only; regenerated material should differ.

This qualification does not establish revocation-policy completeness, certificate pinning, mTLS authorization, service-mesh correctness, cipher performance, ACME renewal behavior, public-certificate scheduling, CA/B Forum lifecycle policy, or compatibility with every TLS client. It qualifies one probe against one explicit trust-and-identity contract.

Build cloud-security validation foundations with Refonte Learning

The operational lesson is broader than remembering two OpenSSL switches: security evidence is useful only when the checker’s own acceptance and rejection paths are understood.

For this laboratory, the evidence is strong because the identity contract is explicit, routing and SNI are separated from hostname verification, trust is supplied through one recorded file, the wrong-name and untrusted fixtures exercise independent failure modes, the real child-process status is preserved, transport failure is classified separately, and an unrelated TLS client is used only as supporting evidence, not as a substitute.

The OpenSSL 3.5 documentation baseline supports the critical semantics: s_client is a diagnostic client; -servername sends SNI; -verify_hostname is the explicit hostname predicate; -verify_return_error makes verification errors operationally rejecting; -showcerts is not a verified chain; and s_client -timeout is not the general TCP timeout that a release harness needs.

For commissioning batch 2026-09-25-batch2, the reproduced OpenSSL 3.5.5 matrix showed the intended result: the strict probe accepted the trusted api.lab.test certificate, rejected the equally trusted other.lab.test certificate, rejected the correct-name certificate from the untrusted second CA, and did not confuse an unavailable transport with certificate evidence. The earlier SNI-only trusted test accepted the wrong-name certificate, proving why that form was not qualified as the release gate.

Engineers building the surrounding cloud-security judgment may also review Refonte Learning’s Cloud Security Engineer Essentials. As described on the programme page accessed September 25, 2026, the programme runs for three months at 10–12 hours per week and lists data encryption, threat detection, incident-response planning, and cloud monitoring/logging among its competency areas. Those are relevant foundations for evaluating evidence-driven security controls such as this one, without implying that the programme specifically teaches this OpenSSL/curl laboratory or promises any particular employment outcome.