TLS/SSL Testing Guide: testssl.sh, sslyze & Certificate Validation
TLS misconfiguration is one of the most common and consequential security issues in web applications. Weak cipher suites, expired certificates, deprecated protocol versions, and missing HSTS — these issues are easy to find with the right tools and easy to automate in CI. This guide covers the full TLS/SSL testing workflow.
What TLS Testing Covers
A complete TLS test validates:
- Protocol versions — TLS 1.2 and 1.3 should be the only supported versions; TLS 1.0, 1.1, and SSLv3 must be disabled
- Cipher suites — weak ciphers (RC4, 3DES, export ciphers) must be disabled; forward secrecy (ECDHE) required
- Certificate chain — certificates must be valid, not expired, signed by a trusted CA, and have a complete chain
- Key size — RSA keys ≥ 2048 bits, EC keys ≥ 256 bits
- Security headers — HSTS, HSTS preloading
- Known vulnerabilities — BEAST, POODLE, HEARTBLEED, ROBOT, DROWN, LOGJAM
testssl.sh
testssl.sh is the most comprehensive open-source TLS scanner. It tests everything and produces a clear pass/warn/fail report.
Installation
# Clone and run directly
git <span class="hljs-built_in">clone --depth 1 https://github.com/drwetter/testssl.sh.git
<span class="hljs-built_in">cd testssl.sh
<span class="hljs-comment"># Docker (recommended for CI)
docker pull drwetter/testssl.shBasic Scan
./testssl.sh https://staging.example.com
# Docker
docker run --<span class="hljs-built_in">rm drwetter/testssl.sh https://staging.example.comOutput example:
Testing protocols via sockets except NPN+ALPN
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)
Testing cipher categories
NULL ciphers (no encryption) not offered (OK)
Anonymous NULL Ciphers (no authentication) not offered (OK)
Export ciphers (w/o ADH+NULL) not offered (OK)
LOW: 64 Bit + DES, RC[2,4], MD5 (w/o export) not offered (OK)
Triple DES Ciphers / IDEA not offered (OK)
Obsoleted CBC ciphers (AES, CAMELLIA, ARIA) not offered (OK)
Strong encryption (AEAD ciphers) offered (OK)
Testing vulnerabilities
Heartbleed (CVE-2014-0160) not vulnerable (OK)
CCS (CVE-2014-0224) not vulnerable (OK)
POODLE, SSL (CVE-2014-3566) not vulnerable (OK)
ROBOT not vulnerable (OK)Output Formats for CI
# JSON output for parsing
./testssl.sh --jsonfile results.json https://staging.example.com
<span class="hljs-comment"># CSV for spreadsheets
./testssl.sh --csvfile results.csv https://staging.example.com
<span class="hljs-comment"># HTML report
./testssl.sh --htmlfile results.html https://staging.example.com
<span class="hljs-comment"># Log file
./testssl.sh --logfile results.log https://staging.example.comTesting Specific Areas
# Protocol support only
./testssl.sh --protocols https://staging.example.com
<span class="hljs-comment"># Cipher suites only
./testssl.sh --cipher-per-proto https://staging.example.com
<span class="hljs-comment"># Certificate only
./testssl.sh --server-certificate https://staging.example.com
<span class="hljs-comment"># Vulnerabilities only
./testssl.sh --vulnerabilities https://staging.example.com
<span class="hljs-comment"># Security headers
./testssl.sh --headers https://staging.example.com
<span class="hljs-comment"># Everything, including client simulation
./testssl.sh --full https://staging.example.comTesting Internal Services
For services not publicly accessible:
# Test by IP
./testssl.sh 10.0.0.50:8443
<span class="hljs-comment"># Test with explicit SNI (Server Name Indication)
./testssl.sh --sni staging.internal.example.com 10.0.0.50:443
<span class="hljs-comment"># Disable certificate verification for self-signed certs in dev
./testssl.sh --insecure https://staging.internal.example.comsslyze
sslyze is a Python-based TLS scanner with structured JSON output that's easier to parse in scripts.
Installation
pip install sslyzeCommand-Line Usage
# Full scan
sslyze staging.example.com
<span class="hljs-comment"># Scan multiple hosts
sslyze staging.example.com api.staging.example.com
<span class="hljs-comment"># JSON output
sslyze --json_out results.json staging.example.com
<span class="hljs-comment"># Specific modules
sslyze --tlsv1 --tlsv1_1 --heartbleed --robot staging.example.comPython API
sslyze has a clean Python API for programmatic use:
import json
from sslyze import Scanner, ServerScanRequest, ScanCommand
from sslyze.errors import ServerHostnameCouldNotBeResolved
def scan_tls(hostname: str, port: int = 443) -> dict:
"""Scan TLS configuration and return findings."""
scanner = Scanner()
# Request all scan commands
request = ServerScanRequest(
server_location=ServerNetworkLocation(hostname=hostname, port=port),
scan_commands={
ScanCommand.SSL_2_0_CIPHER_SUITES,
ScanCommand.SSL_3_0_CIPHER_SUITES,
ScanCommand.TLS_1_0_CIPHER_SUITES,
ScanCommand.TLS_1_1_CIPHER_SUITES,
ScanCommand.TLS_1_2_CIPHER_SUITES,
ScanCommand.TLS_1_3_CIPHER_SUITES,
ScanCommand.CERTIFICATE_INFO,
ScanCommand.HEARTBLEED,
ScanCommand.ROBOT,
ScanCommand.HTTP_HEADERS,
},
)
scanner.queue_scans([request])
findings = {
'hostname': hostname,
'issues': [],
'passed': True
}
for result in scanner.get_results():
if result.scan_status == ServerScanStatusEnum.ERROR_NO_CONNECTIVITY:
findings['issues'].append(f'Could not connect to {hostname}:{port}')
findings['passed'] = False
continue
# Check deprecated protocols
for protocol, command in [
('SSLv2', ScanCommand.SSL_2_0_CIPHER_SUITES),
('SSLv3', ScanCommand.SSL_3_0_CIPHER_SUITES),
('TLS 1.0', ScanCommand.TLS_1_0_CIPHER_SUITES),
('TLS 1.1', ScanCommand.TLS_1_1_CIPHER_SUITES),
]:
scan_result = result.scan_result.__dict__.get(command.value.lower().replace('.', '_'))
if scan_result and scan_result.accepted_cipher_suites:
findings['issues'].append(f'{protocol} is supported — must be disabled')
findings['passed'] = False
# Check certificate
cert_result = result.scan_result.certificate_info
if cert_result:
for deployment in cert_result.certificate_deployments:
if not deployment.verified_certificate_chain:
findings['issues'].append('Certificate chain incomplete or untrusted')
findings['passed'] = False
# Check expiry
leaf_cert = deployment.received_certificate_chain[0]
from datetime import datetime, timezone
expires = leaf_cert.not_valid_after_utc
days_until_expiry = (expires - datetime.now(timezone.utc)).days
if days_until_expiry < 30:
findings['issues'].append(f'Certificate expires in {days_until_expiry} days')
findings['passed'] = False
# Check Heartbleed
heartbleed = result.scan_result.heartbleed
if heartbleed and heartbleed.is_vulnerable_to_heartbleed:
findings['issues'].append('Vulnerable to Heartbleed (CVE-2014-0160)')
findings['passed'] = False
# Check ROBOT
robot = result.scan_result.robot
if robot and robot.robot_result.value in ('VULNERABLE_STRONG_ORACLE', 'VULNERABLE_WEAK_ORACLE'):
findings['issues'].append('Vulnerable to ROBOT attack')
findings['passed'] = False
# Check HSTS
headers = result.scan_result.http_headers
if headers and not headers.strict_transport_security_header:
findings['issues'].append('HSTS header missing')
# Not a hard fail — warn only
return findings
if __name__ == '__main__':
import sys
hostname = sys.argv[1] if len(sys.argv) > 1 else 'example.com'
result = scan_tls(hostname)
print(json.dumps(result, indent=2))
sys.exit(0 if result['passed'] else 1)Certificate Validation Testing
Manual Certificate Checks
# View certificate details
openssl s_client -connect staging.example.com:443 -servername staging.example.com \
</dev/null 2>/dev/null <span class="hljs-pipe">| openssl x509 -noout -text
<span class="hljs-comment"># Check expiry
openssl s_client -connect staging.example.com:443 \
</dev/null 2>/dev/null <span class="hljs-pipe">| openssl x509 -noout -dates
<span class="hljs-comment"># Check the full chain
openssl s_client -connect staging.example.com:443 \
-showcerts </dev/null 2>/dev/null
<span class="hljs-comment"># Verify against system trust store
openssl s_client -connect staging.example.com:443 \
-verify_return_error </dev/null
<span class="hljs-comment"># Check SANs (Subject Alternative Names)
openssl s_client -connect staging.example.com:443 \
</dev/null 2>/dev/null <span class="hljs-pipe">| openssl x509 -noout -ext subjectAltNameCertificate Expiry Monitoring
#!/bin/bash
<span class="hljs-comment"># check-cert-expiry.sh — alerts if cert expires within N days
HOSTS=(
<span class="hljs-string">"staging.example.com:443"
<span class="hljs-string">"api.staging.example.com:443"
<span class="hljs-string">"internal.example.com:8443"
)
WARN_DAYS=30
CRITICAL_DAYS=7
<span class="hljs-keyword">for HOST_PORT <span class="hljs-keyword">in <span class="hljs-string">"${HOSTS[@]}"; <span class="hljs-keyword">do
HOST=<span class="hljs-string">"${HOST_PORT%%:*}"
PORT=<span class="hljs-string">"${HOST_PORT##*:}"
EXPIRY=$(openssl s_client -connect <span class="hljs-string">"$HOST_PORT" -servername <span class="hljs-string">"$HOST" \
</dev/null 2>/dev/null <span class="hljs-pipe">| openssl x509 -noout -enddate <span class="hljs-pipe">| <span class="hljs-built_in">cut -d= -f2)
EXPIRY_EPOCH=$(<span class="hljs-built_in">date -d <span class="hljs-string">"$EXPIRY" +%s 2>/dev/null <span class="hljs-pipe">|| <span class="hljs-built_in">date -j -f <span class="hljs-string">"%b %d %T %Y %Z" <span class="hljs-string">"$EXPIRY" +%s)
NOW_EPOCH=$(<span class="hljs-built_in">date +%s)
DAYS_REMAINING=$(( (EXPIRY_EPOCH - NOW_EPOCH) / <span class="hljs-number">86400 ))
<span class="hljs-keyword">if [ <span class="hljs-string">"$DAYS_REMAINING" -lt <span class="hljs-string">"$CRITICAL_DAYS" ]; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"CRITICAL: $HOST cert expires in <span class="hljs-variable">$DAYS_REMAINING days (<span class="hljs-variable">$EXPIRY)"
<span class="hljs-built_in">exit 2
<span class="hljs-keyword">elif [ <span class="hljs-string">"$DAYS_REMAINING" -lt <span class="hljs-string">"$WARN_DAYS" ]; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"WARNING: $HOST cert expires in <span class="hljs-variable">$DAYS_REMAINING days (<span class="hljs-variable">$EXPIRY)"
<span class="hljs-keyword">else
<span class="hljs-built_in">echo <span class="hljs-string">"OK: $HOST cert valid for <span class="hljs-variable">$DAYS_REMAINING days"
<span class="hljs-keyword">fi
<span class="hljs-keyword">doneCipher Suite Testing
Testing Specific Cipher Suites
# List all accepted ciphers on TLS 1.2
nmap --script ssl-enum-ciphers -p 443 staging.example.com
<span class="hljs-comment"># Test if a specific weak cipher is supported
openssl s_client -connect staging.example.com:443 \
-cipher <span class="hljs-string">"DES-CBC3-SHA" 2>&1 <span class="hljs-pipe">| grep -E <span class="hljs-string">"(CONNECTED|handshake failure)"
<span class="hljs-comment"># Test RC4 (should fail)
openssl s_client -connect staging.example.com:443 \
-cipher <span class="hljs-string">"RC4-SHA" 2>&1 <span class="hljs-pipe">| grep -E <span class="hljs-string">"(CONNECTED|handshake failure)"
<span class="hljs-comment"># Verify forward secrecy (ECDHE should be in cipher)
openssl s_client -connect staging.example.com:443 \
</dev/null 2>/dev/null <span class="hljs-pipe">| grep <span class="hljs-string">"Cipher is"Cipher Suite Benchmarking
# Test supported cipher list with testssl.sh
./testssl.sh --cipher-per-proto staging.example.com
<span class="hljs-comment"># Check for weak ciphers to reject
./testssl.sh --cipher-per-proto staging.example.com 2>/dev/null <span class="hljs-pipe">| grep -E <span class="hljs-string">"(MEDIUM|WEAK|NULL)"Cipher suites to verify are not offered:
RC4— broken, biased keystream3DES / DES-CBC3— SWEET32 attack (birthday attack on 64-bit blocks)NULLencryption — no encryption at allEXPORTciphers — deliberately weakened for export compliance (now exploitable)DH < 2048 bits— Logjam vulnerableANON— no authentication
Cipher suites to verify are offered:
ECDHE-*-AES*-GCM-SHA*— AEAD, forward secrecyTLS_AES_256_GCM_SHA384— TLS 1.3TLS_CHACHA20_POLY1305_SHA256— TLS 1.3
CI/CD Integration
GitHub Actions with testssl.sh
name: TLS Security Check
on:
push:
branches: [main]
schedule:
- cron: '0 6 * * *' # Daily certificate expiry check
jobs:
tls-check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run testssl.sh
run: |
docker run --rm drwetter/testssl.sh \
--jsonfile /tmp/tls-results.json \
--quiet \
${{ vars.STAGING_URL }}
- name: Parse results
run: |
# Fail on HIGH or CRITICAL findings
FAILURES=$(cat /tmp/tls-results.json | \
jq -r '.[] | select(.severity == "HIGH" or .severity == "CRITICAL") | "\(.severity): \(.id) - \(.finding)"')
if [ -n "$FAILURES" ]; then
echo "TLS issues found:"
echo "$FAILURES"
exit 1
fi
echo "TLS check passed"
- name: Upload TLS report
uses: actions/upload-artifact@v4
if: always()
with:
name: tls-report
path: /tmp/tls-results.jsonPre-Deployment Gate
Add to your deployment pipeline to block deployments when TLS config regresses:
#!/bin/bash
<span class="hljs-comment"># tls-gate.sh
TARGET=<span class="hljs-string">"$1"
<span class="hljs-built_in">echo <span class="hljs-string">"Running TLS check for $TARGET..."
<span class="hljs-comment"># Run sslyze and save JSON
sslyze --json_out /tmp/sslyze-results.json <span class="hljs-string">"$TARGET" > /dev/null 2>&1
<span class="hljs-comment"># Check for critical issues
python3 - <<<span class="hljs-string">'EOF'
import json, sys
with open(<span class="hljs-string">'/tmp/sslyze-results.json') as f:
results = json.load(f)
failures = []
<span class="hljs-keyword">for result <span class="hljs-keyword">in results.get(<span class="hljs-string">'server_scan_results', []):
scan = result.get(<span class="hljs-string">'scan_result', {})
<span class="hljs-comment"># Check deprecated protocols
<span class="hljs-keyword">for proto <span class="hljs-keyword">in [<span class="hljs-string">'ssl_2_0_cipher_suites', <span class="hljs-string">'ssl_3_0_cipher_suites', <span class="hljs-string">'tls_1_0_cipher_suites', <span class="hljs-string">'tls_1_1_cipher_suites']:
<span class="hljs-keyword">if scan.get(proto, {}).get(<span class="hljs-string">'accepted_cipher_suites'):
failures.append(f<span class="hljs-string">'Deprecated protocol supported: {proto}')
<span class="hljs-comment"># Check Heartbleed
<span class="hljs-keyword">if scan.get(<span class="hljs-string">'heartbleed', {}).get(<span class="hljs-string">'is_vulnerable_to_heartbleed'):
failures.append(<span class="hljs-string">'Vulnerable to Heartbleed')
<span class="hljs-comment"># Check certificate
<span class="hljs-keyword">for deployment <span class="hljs-keyword">in scan.get(<span class="hljs-string">'certificate_info', {}).get(<span class="hljs-string">'certificate_deployments', []):
<span class="hljs-keyword">if not deployment.get(<span class="hljs-string">'verified_certificate_chain'):
failures.append(<span class="hljs-string">'Invalid certificate chain')
<span class="hljs-keyword">if failures:
<span class="hljs-built_in">print(<span class="hljs-string">'TLS FAILURES:')
<span class="hljs-keyword">for f <span class="hljs-keyword">in failures:
<span class="hljs-built_in">print(f<span class="hljs-string">' - {f}')
sys.exit(1)
<span class="hljs-built_in">print(<span class="hljs-string">'TLS check: PASSED')
sys.exit(0)
EOFCommon TLS Issues and Fixes
| Issue | Risk | Fix |
|---|---|---|
| TLS 1.0/1.1 enabled | POODLE, BEAST | Set ssl_protocols TLSv1.2 TLSv1.3; in nginx |
| RC4/3DES ciphers | Data decryption | Remove from ssl_ciphers config |
| Missing HSTS | Downgrade attacks | Add Strict-Transport-Security: max-age=31536000 |
| Self-signed certificate in prod | MitM attacks | Use Let's Encrypt or a trusted CA |
| Certificate expiry | Service outage | Automate renewal (Let's Encrypt + certbot) |
| Weak DH parameters | Logjam | Generate strong DH params: openssl dhparam -out dhparam.pem 2048 |
| Missing OCSP stapling | Performance, privacy | Enable in nginx with ssl_stapling on |
Nginx TLS Configuration Reference
A configuration that passes all major TLS tests:
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/ssl/certs/example.com.crt;
ssl_certificate_key /etc/ssl/private/example.com.key;
# Only TLS 1.2 and 1.3
ssl_protocols TLSv1.2 TLSv1.3;
# Strong cipher suites with forward secrecy
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:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# HSTS — 1 year, including subdomains
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/ssl/certs/chain.pem;
resolver 8.8.8.8 8.8.4.4 valid=300s;
# DH parameters for DHE ciphers
ssl_dhparam /etc/nginx/dhparam.pem;
# Session management
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m;
ssl_session_tickets off;
}Test this configuration gets an A+ on SSL Labs before deploying to production.