Encryption in Transit: TLS Configuration
Transport Layer Security (TLS) is the protocol that keeps your data safe as it travels across networks. Every HTTPS connection, every secure API call, every encrypted email relay depends on TLS doing...
Key Insights
- TLS 1.3 should be your default protocol—it’s faster, more secure, and eliminates entire categories of vulnerabilities present in older versions
- Forward secrecy through ECDHE cipher suites ensures that compromising your private key doesn’t expose past communications
- Automated certificate management with tools like Certbot eliminates the most common cause of TLS failures: expired certificates
Introduction to TLS and Why It Matters
Transport Layer Security (TLS) is the protocol that keeps your data safe as it travels across networks. Every HTTPS connection, every secure API call, every encrypted email relay depends on TLS doing its job correctly. Get it wrong, and you’re either leaking sensitive data or breaking compatibility with legitimate clients.
The evolution from SSL 3.0 through TLS 1.0, 1.1, 1.2, and now 1.3 represents decades of cryptographic improvements and vulnerability patches. SSL and TLS 1.0/1.1 are officially deprecated. If you’re still supporting them, you’re actively choosing to be vulnerable to attacks like POODLE and BEAST.
TLS 1.3, finalized in 2018, removed broken features, simplified the handshake, and made forward secrecy mandatory. There’s no good reason to avoid it in 2024.
TLS Handshake Explained
Understanding the handshake helps you debug connection failures and make informed configuration decisions. Here’s the simplified flow:
- Client Hello: Client sends supported TLS versions, cipher suites, and a random value
- Server Hello: Server selects protocol version and cipher suite, sends its random value
- Certificate: Server sends its certificate chain
- Key Exchange: Both parties establish shared secrets using asymmetric cryptography
- Finished: Both sides verify the handshake integrity and switch to encrypted communication
TLS 1.3 collapses this into fewer round trips, reducing latency significantly.
You can observe the handshake using OpenSSL’s debug output:
openssl s_client -connect example.com:443 -state -debug 2>&1 | grep -E "(SSL_connect|Cipher|Protocol)"
For more detailed analysis:
openssl s_client -connect example.com:443 -msg <<< "Q" 2>&1 | head -50
This shows you the actual messages exchanged. You’ll see ClientHello, ServerHello, Certificate, and Finished messages in sequence. When debugging connection failures, this output tells you exactly where the handshake breaks down.
Configuring TLS for Web Servers
Nginx Configuration
Here’s a production-ready Nginx TLS configuration:
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com;
# Certificate files
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Protocol versions - TLS 1.2 and 1.3 only
ssl_protocols TLSv1.2 TLSv1.3;
# Cipher suites - TLS 1.3 ciphers are configured automatically
# These are for TLS 1.2 fallback
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;
ssl_prefer_server_ciphers off;
# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
resolver 8.8.8.8 8.8.4.4 valid=300s;
resolver_timeout 5s;
# Session configuration
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
# Security headers
add_header Strict-Transport-Security "max-age=63072000" always;
}
Note that ssl_prefer_server_ciphers off is intentional for TLS 1.3—clients generally make better cipher choices now, and this allows them to prefer ChaCha20 on mobile devices where it’s faster than AES without hardware acceleration.
Apache Configuration
The equivalent Apache configuration:
<VirtualHost *:443>
ServerName example.com
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
# Protocol versions
SSLProtocol -all +TLSv1.2 +TLSv1.3
# Cipher suites
SSLCipherSuite 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
SSLHonorCipherOrder off
# OCSP stapling
SSLUseStapling on
SSLStaplingCache "shmcb:logs/ssl_stapling(32768)"
# HSTS
Header always set Strict-Transport-Security "max-age=63072000"
</VirtualHost>
Certificate Management Best Practices
Let’s Encrypt changed the game for certificate management. There’s no excuse for expired certificates when automation is this simple.
Install Certbot and obtain your initial certificate:
# Install Certbot (Debian/Ubuntu)
apt update && apt install certbot python3-certbot-nginx
# Obtain certificate with automatic Nginx configuration
certbot --nginx -d example.com -d www.example.com
# Or obtain certificate only (manual configuration)
certbot certonly --webroot -w /var/www/html -d example.com
Certbot installs a systemd timer for automatic renewal. Verify it’s active:
systemctl status certbot.timer
For custom renewal hooks (like reloading Nginx), create a deploy hook:
#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
systemctl reload nginx
Make it executable:
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
Test the renewal process without actually renewing:
certbot renew --dry-run
Private key protection is critical. Your private keys should have restricted permissions:
chmod 600 /etc/letsencrypt/live/example.com/privkey.pem
chown root:root /etc/letsencrypt/live/example.com/privkey.pem
Hardening Cipher Suites and Protocol Versions
Cipher suite selection involves trade-offs between security and compatibility. Here are three profiles:
Modern (TLS 1.3 only, maximum security)
ssl_protocols TLSv1.3;
# TLS 1.3 cipher suites are fixed and secure by default
This breaks compatibility with older clients but provides the strongest security.
Intermediate (Recommended for most deployments)
ssl_protocols TLSv1.2 TLSv1.3;
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;
This supports clients from roughly 2014 onward while maintaining forward secrecy.
Legacy (When you must support old clients)
ssl_protocols TLSv1.2 TLSv1.3;
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;
Never include RC4, 3DES, or export ciphers. Never enable SSLv3 or TLS 1.0/1.1.
Testing and Validating TLS Configuration
After configuration changes, validate your setup before considering it production-ready.
Quick OpenSSL test
# Test connection and show certificate
openssl s_client -connect example.com:443 -servername example.com <<< "Q"
# Test specific TLS version
openssl s_client -connect example.com:443 -tls1_3 <<< "Q"
# Verify TLS 1.1 is rejected (should fail)
openssl s_client -connect example.com:443 -tls1_1 <<< "Q"
Comprehensive testing with testssl.sh
# Install testssl.sh
git clone --depth 1 https://github.com/drwetter/testssl.sh.git
cd testssl.sh
# Run full scan
./testssl.sh example.com
# Quick scan focusing on vulnerabilities
./testssl.sh --vulnerable example.com
# Check cipher suites only
./testssl.sh --cipher-per-proto example.com
SSL Labs
For public-facing servers, run your domain through SSL Labs. Aim for an A+ rating. Common issues that prevent A+:
- Missing HSTS header
- Supporting TLS 1.0/1.1
- Weak cipher suites in the list
- Incomplete certificate chain
Common Pitfalls and Troubleshooting
Certificate chain problems
The most common TLS issue is an incomplete certificate chain. Your server must send intermediate certificates, not just the leaf certificate.
Diagnose chain issues:
# Show full certificate chain
openssl s_client -connect example.com:443 -servername example.com -showcerts <<< "Q"
# Verify chain against system trust store
openssl s_client -connect example.com:443 -servername example.com -verify_return_error <<< "Q"
If you see “unable to verify the first certificate,” you’re missing intermediates. With Let’s Encrypt, use fullchain.pem, not cert.pem.
Mixed content warnings
After enabling HTTPS, browsers block HTTP resources loaded on HTTPS pages. Audit your site:
# Find HTTP references in your codebase
grep -r "http://" --include="*.html" --include="*.js" --include="*.css" .
Use protocol-relative URLs (//example.com/resource) or HTTPS URLs exclusively.
HSTS considerations
HTTP Strict Transport Security tells browsers to always use HTTPS. Once set, you can’t easily go back to HTTP.
Start with a short max-age during testing:
add_header Strict-Transport-Security "max-age=300" always;
Once confident, increase to two years and add preload eligibility:
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
Debugging connection failures
When clients can’t connect, gather information systematically:
# Check what the server actually offers
openssl s_client -connect example.com:443 -servername example.com -cipher 'ALL' <<< "Q" 2>&1 | grep -E "(Cipher|Protocol)"
# Test from client's perspective with specific cipher
openssl s_client -connect example.com:443 -cipher 'ECDHE-RSA-AES128-GCM-SHA256' <<< "Q"
# Check certificate dates
echo | openssl s_client -connect example.com:443 2>/dev/null | openssl x509 -noout -dates
TLS configuration isn’t set-and-forget. Cryptographic best practices evolve, and your configuration should evolve with them. Schedule quarterly reviews of your TLS setup, re-run SSL Labs tests, and stay informed about newly discovered vulnerabilities. The effort pays dividends in both security and user trust.