Apache SSL/TLS Hardening: Get an A+ on SSL Labs
A default Apache SSL configuration will get you a "B" on Qualys SSL Labs at best — and that's if you're lucky. Out-of-the-box settings still support outdated protocols, weak ciphers, and missing security headers that any competent attacker will exploit. This guide walks through every setting you need to go from mediocre to A+.
Prerequisites
- Apache 2.4.x (2.4.36+ strongly recommended for TLS 1.3 support)
- OpenSSL 1.1.1+ on the server
- A valid certificate (Let's Encrypt works fine)
mod_sslandmod_headersenabled
sudo a2enmod ssl headers
apache2ctl -M | grep -E "ssl|headers"
The Baseline: Disable Old Protocols
TLS 1.0 and 1.1 are deprecated. SSLv2 and SSLv3 have been broken for years. Disable them all and only allow TLS 1.2 and 1.3:
# /etc/apache2/mods-available/ssl.conf or your VirtualHost
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
This keeps TLS 1.2 and 1.3 (the all enables everything, then we subtract the old ones). To verify what protocols your server actually negotiates:
# Test TLS 1.1 — should fail
openssl s_client -connect example.com:443 -tls1_1 2>&1 | grep -E "Cipher|Protocol|error"
# Test TLS 1.2 — should succeed
openssl s_client -connect example.com:443 -tls1_2 2>&1 | grep -E "Protocol"
# Test TLS 1.3 — should succeed
openssl s_client -connect example.com:443 -tls1_3 2>&1 | grep -E "Protocol"
Cipher Suite Configuration
This is where most people get it wrong. The cipher string controls which algorithms Apache will negotiate. A modern, secure configuration:
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:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
# Let the server choose cipher order (not the client)
SSLHonorCipherOrder on
# Disable compression — CRIME attack vector
SSLCompression off
# Disable session tickets for perfect forward secrecy
SSLSessionTickets off
For TLS 1.3, cipher configuration works differently — the TLS 1.3 ciphers are set separately and are all secure by default. You can optionally restrict them:
# TLS 1.3 ciphers (all are good — this is optional hardening)
SSLOpenSSLConfCmd Ciphersuites TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256
Cipher Suite Cheat Sheet
| Cipher | Forward Secrecy | Recommended |
|---|---|---|
| ECDHE-RSA-AES256-GCM-SHA384 | Yes (ECDHE) | Yes |
| ECDHE-RSA-CHACHA20-POLY1305 | Yes (ECDHE) | Yes |
| AES256-GCM-SHA384 | No | No |
| RC4-SHA | No | Never |
| DES-CBC3-SHA | No | Never |
HSTS: Force HTTPS at the Browser Level
HTTP Strict Transport Security tells browsers to never connect over plain HTTP again. Once set, even if your redirect disappears, browsers refuse to make the insecure connection:
# Add inside your HTTPS VirtualHost
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Important: Start with a short max-age (e.g., 300) and test for a week before setting it to one year. A misconfigured HSTS header with a long max-age can lock users out if your certificate breaks.
To get on the HSTS preload list (which gets baked into browsers), you need:
max-ageof at least 31536000 (1 year)includeSubDomainspresentpreloadpresent- All subdomains must also serve valid HTTPS
OCSP Stapling
Without OCSP stapling, every TLS handshake requires the client to contact your CA's OCSP responder to check if your certificate has been revoked. This adds latency and a privacy leak. Stapling caches the OCSP response on your server:
SSLUseStapling on
SSLStaplingCache "shmcb:/var/run/apache2/stapling(32768)"
SSLStaplingResponseMaxAge 900
# Inside your VirtualHost:
SSLStaplingReturnResponderErrors off
SSLStaplingFakeStartTLSExt on
The SSLStaplingCache directive must be in the global server config or a top-level context, not inside a VirtualHost.
Verify stapling is working:
openssl s_client -connect example.com:443 -status 2>&1 | grep -A 10 "OCSP Response"
# Look for: OCSP Response Status: successful
Diffie-Hellman Parameters
Weak DH parameters (the Logjam attack used 1024-bit params) undermine forward secrecy. Generate strong 4096-bit parameters:
# This takes a few minutes — run it once
openssl dhparam -out /etc/ssl/dhparams.pem 4096
SSLOpenSSLConfCmd DHParameters "/etc/ssl/dhparams.pem"
Using 2048-bit is acceptable if 4096-bit generation time is a concern; 4096-bit is overkill for most but doesn't hurt performance in practice since DH is only used during the handshake.
Complete Hardened VirtualHost Configuration
Putting it all together — a production-ready HTTPS VirtualHost:
<VirtualHost *:80>
ServerName example.com
ServerAlias www.example.com
# Redirect all HTTP to HTTPS
RewriteEngine On
RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [R=301,L]
</VirtualHost>
<VirtualHost *:443>
ServerName example.com
ServerAlias www.example.com
DocumentRoot /var/www/html
# Certificate
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
# Protocol hardening
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
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:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
SSLHonorCipherOrder on
SSLCompression off
SSLSessionTickets off
# DH parameters
SSLOpenSSLConfCmd DHParameters "/etc/ssl/dhparams.pem"
# OCSP stapling
SSLUseStapling on
# Security headers
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "DENY"
Header always set X-XSS-Protection "1; mode=block"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
# Remove fingerprinting headers
Header unset X-Powered-By
Header always unset Server
ServerTokens Prod
ServerSignature Off
ErrorLog /var/log/apache2/ssl-error.log
CustomLog /var/log/apache2/ssl-access.log combined
</VirtualHost>
Content Security Policy
CSP is the most powerful browser-enforced security header but requires understanding your app's resource usage. Start in report-only mode:
# Report-only first — doesn't break anything, just logs violations
Header always set Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; report-uri https://example.com/csp-report"
# Once validated, switch to enforcing:
# Header always set Content-Security-Policy "default-src 'self'; ..."
SSL Labs Score Checklist
Run through these before testing:
| Category | Setting | Required For A+ |
|---|---|---|
| Certificate | Valid, trusted chain | Yes |
| Protocol support | TLS 1.2 + 1.3 only | Yes |
| Cipher strength | ECDHE + AES-GCM | Yes |
| Forward secrecy | ECDHE or DHE ciphers | Yes |
| HSTS | max-age >= 6 months | Yes |
| OCSP stapling | Enabled | Yes |
| DH params | 2048-bit minimum | Yes |
| Cipher order | Server-preferred | Yes |
# Quick local test with testssl.sh
docker run --rm -it drwetter/testssl.sh example.com
# Or install directly
git clone https://github.com/drwetter/testssl.sh.git
./testssl.sh/testssl.sh example.com
After applying all changes:
apache2ctl configtest && sudo systemctl reload apache2
Then head to ssllabs.com/ssltest and run the scan. With this configuration, you should see an A+ without caveats.
Was this article helpful?
CI/CD Engineering Lead
Automation evangelist who believes no deployment should require a human. I write pipelines, break pipelines, and write about both. Code-first, always.
Related Articles
HAProxy Advanced: SSL Termination, SNI Routing, and mTLS
Advanced HAProxy SSL configuration — multi-domain SNI routing from a single frontend, mutual TLS (mTLS) for service-to-service authentication, certificate management, and OCSP stapling.
Apache 301 Redirect Loop: Diagnosing And Fixing Infinite Redirect Chains In VirtualHost And .htaccess Configurations
Few things are more frustrating than deploying what you think is a clean redirect configuration, only to have your browser throw an `ERR_TOO_MANY_REDIRECTS...
Apache 413 Request Entity Too Large: Fixing File Upload Failures With LimitRequestBody And PHP Configuration
The 413 error means Apache is rejecting your upload before it even hits PHP. Here's exactly what to change and where. Two layers can block large uploads: 1...
Apache 500 Internal Server Error: Diagnosing .htaccess Syntax Failures And Module Conflicts Step By Step
If you've been running web infrastructure long enough, you know the 500 Internal Server Error is Apache's way of saying "something's broken, and I'm not go...
Apache Mod_status Real-Time Server Monitoring With Grafana And Prometheus Integration
Monitoring Apache without proper observability tooling is like flying blind. You might catch a problem after your users start complaining, but by then, you...
Apache Mod_security WAF Rules For OWASP Top 10 Protection
If you're running Apache in production without a Web Application Firewall, you're essentially leaving your front door unlocked. mod_security is the industr...
More in Apache
View all →Apache 403 Forbidden Error After Directory Permissions Fix: Troubleshooting SELinux And File Contexts
You've just spent an hour wrestling with Apache file permissions, carefully setting ownership to `apache:apache` and permissions to `755` on your directori...
Fix Apache 'AH00558: Could not reliably determine server's FQDN'
Resolve the Apache AH00558 warning about not being able to determine the server's fully qualified domain name by setting ServerName correctly.
Fix Apache mod_rewrite Not Working
Diagnose and fix Apache mod_rewrite rules not being applied, covering module loading, AllowOverride settings, .htaccess issues, and rewrite logging.
Apache Performance Tuning: MPM, KeepAlive, and Caching
Tune Apache performance by choosing the right MPM, configuring KeepAlive correctly, and enabling mod_cache for significant throughput gains.