TLS/SSL Testing Guide: testssl.sh, sslyze & Certificate Validation

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

Basic Scan

./testssl.sh https://staging.example.com

# Docker
docker run --<span class="hljs-built_in">rm drwetter/testssl.sh https://staging.example.com

Output 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.com

Testing 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.com

Testing 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.com

sslyze

sslyze is a Python-based TLS scanner with structured JSON output that's easier to parse in scripts.

Installation

pip install sslyze

Command-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.com

Python 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 subjectAltName

Certificate 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">done

Cipher 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 keystream
  • 3DES / DES-CBC3 — SWEET32 attack (birthday attack on 64-bit blocks)
  • NULL encryption — no encryption at all
  • EXPORT ciphers — deliberately weakened for export compliance (now exploitable)
  • DH < 2048 bits — Logjam vulnerable
  • ANON — no authentication

Cipher suites to verify are offered:

  • ECDHE-*-AES*-GCM-SHA* — AEAD, forward secrecy
  • TLS_AES_256_GCM_SHA384 — TLS 1.3
  • TLS_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.json

Pre-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)
EOF

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

Read more