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 clone --depth 1 https://github.com/drwetter/testssl.sh.git
cd testssl.sh
# Docker (recommended for CI)
docker pull drwetter/testssl.shBasic Scan
./testssl.sh https://staging.example.com
# Docker
docker run --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
# CSV for spreadsheets
./testssl.sh --csvfile results.csv https://staging.example.com
# HTML report
./testssl.sh --htmlfile results.html https://staging.example.com
# Log file
./testssl.sh --logfile results.log https://staging.example.comTesting Specific Areas
# Protocol support only
./testssl.sh --protocols https://staging.example.com
# Cipher suites only
./testssl.sh --cipher-per-proto https://staging.example.com
# Certificate only
./testssl.sh --server-certificate https://staging.example.com
# Vulnerabilities only
./testssl.sh --vulnerabilities https://staging.example.com
# Security headers
./testssl.sh --headers https://staging.example.com
# 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
# Test with explicit SNI (Server Name Indication)
./testssl.sh --sni staging.internal.example.com 10.0.0.50:443
# 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
# Scan multiple hosts
sslyze staging.example.com api.staging.example.com
# JSON output
sslyze --json_out results.json staging.example.com
# 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 | openssl x509 -noout -text
# Check expiry
openssl s_client -connect staging.example.com:443 \
</dev/null 2>/dev/null | openssl x509 -noout -dates
# Check the full chain
openssl s_client -connect staging.example.com:443 \
-showcerts </dev/null 2>/dev/null
# Verify against system trust store
openssl s_client -connect staging.example.com:443 \
-verify_return_error </dev/null
# Check SANs (Subject Alternative Names)
openssl s_client -connect staging.example.com:443 \
</dev/null 2>/dev/null | openssl x509 -noout -ext subjectAltNameCertificate Expiry Monitoring
#!/bin/bash
# check-cert-expiry.sh — alerts if cert expires within N days
HOSTS=(
"staging.example.com:443"
"api.staging.example.com:443"
"internal.example.com:8443"
)
WARN_DAYS=30
CRITICAL_DAYS=7
for HOST_PORT in "${HOSTS[@]}"; do
HOST="${HOST_PORT%%:*}"
PORT="${HOST_PORT##*:}"
EXPIRY=$(openssl s_client -connect "$HOST_PORT" -servername "$HOST" \
</dev/null 2>/dev/null | openssl x509 -noout -enddate | cut -d= -f2)
EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s 2>/dev/null || date -j -f "%b %d %T %Y %Z" "$EXPIRY" +%s)
NOW_EPOCH=$(date +%s)
DAYS_REMAINING=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))
if [ "$DAYS_REMAINING" -lt "$CRITICAL_DAYS" ]; then
echo "CRITICAL: $HOST cert expires in $DAYS_REMAINING days ($EXPIRY)"
exit 2
elif [ "$DAYS_REMAINING" -lt "$WARN_DAYS" ]; then
echo "WARNING: $HOST cert expires in $DAYS_REMAINING days ($EXPIRY)"
else
echo "OK: $HOST cert valid for $DAYS_REMAINING days"
fi
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
# Test if a specific weak cipher is supported
openssl s_client -connect staging.example.com:443 \
-cipher "DES-CBC3-SHA" 2>&1 | grep -E "(CONNECTED|handshake failure)"
# Test RC4 (should fail)
openssl s_client -connect staging.example.com:443 \
-cipher "RC4-SHA" 2>&1 | grep -E "(CONNECTED|handshake failure)"
# Verify forward secrecy (ECDHE should be in cipher)
openssl s_client -connect staging.example.com:443 \
</dev/null 2>/dev/null | grep "Cipher is"Cipher Suite Benchmarking
# Test supported cipher list with testssl.sh
./testssl.sh --cipher-per-proto staging.example.com
# Check for weak ciphers to reject
./testssl.sh --cipher-per-proto staging.example.com 2>/dev/null | grep -E "(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
# tls-gate.sh
TARGET="$1"
echo "Running TLS check for $TARGET..."
# Run sslyze and save JSON
sslyze --json_out /tmp/sslyze-results.json "$TARGET" > /dev/null 2>&1
# Check for critical issues
python3 - <<'EOF'
import json, sys
with open('/tmp/sslyze-results.json') as f:
results = json.load(f)
failures = []
for result in results.get('server_scan_results', []):
scan = result.get('scan_result', {})
# Check deprecated protocols
for proto in ['ssl_2_0_cipher_suites', 'ssl_3_0_cipher_suites', 'tls_1_0_cipher_suites', 'tls_1_1_cipher_suites']:
if scan.get(proto, {}).get('accepted_cipher_suites'):
failures.append(f'Deprecated protocol supported: {proto}')
# Check Heartbleed
if scan.get('heartbleed', {}).get('is_vulnerable_to_heartbleed'):
failures.append('Vulnerable to Heartbleed')
# Check certificate
for deployment in scan.get('certificate_info', {}).get('certificate_deployments', []):
if not deployment.get('verified_certificate_chain'):
failures.append('Invalid certificate chain')
if failures:
print('TLS FAILURES:')
for f in failures:
print(f' - {f}')
sys.exit(1)
print('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.