1015 TLS Handshake Failure — Certificate Mismatch
Symptome
- Browser console shows 'WebSocket connection to wss://... failed: net::ERR_CERT_COMMON_NAME_INVALID'
- `ws.onerror` fires immediately after `ws.onopen` is never called
- Connection works over `ws://` (non-TLS) but fails over `wss://`
- `openssl s_client` shows 'verify error: num=62: Hostname mismatch'
- Certificate in DevTools Security tab is issued for a different domain than the WebSocket hostname
- `ws.onerror` fires immediately after `ws.onopen` is never called
- Connection works over `ws://` (non-TLS) but fails over `wss://`
- `openssl s_client` shows 'verify error: num=62: Hostname mismatch'
- Certificate in DevTools Security tab is issued for a different domain than the WebSocket hostname
Grundursachen
- SSL certificate does not cover the WebSocket server hostname (missing SAN or wildcard)
- Self-signed certificate used in production — not trusted by the browser's CA store
- Certificate has expired or been revoked
- TLS version mismatch — server requires TLS 1.3 but the client or proxy only supports TLS 1.2
- Mixed content — main page loaded over HTTPS but WebSocket attempting ws:// instead of wss://
Diagnose
**Step 1 — Reproduce the error in the browser**
```javascript
// Try both ws:// and wss://:
const wsInsecure = new WebSocket('ws://api.example.com/ws');
const wsSecure = new WebSocket('wss://api.example.com/ws');
wsSecure.onerror = (event) => {
console.error('wss failed:', event);
};
// If wsInsecure connects but wsSecure fails → TLS issue confirmed
```
**Step 2 — Inspect the certificate with openssl**
```bash
# Connect to the WebSocket server's TLS port and show certificate details:
openssl s_client -connect api.example.com:443 -servername api.example.com
# Look for:
# CN (common name) and SAN (Subject Alternative Names)
# Verify return code: 0 (ok) or error code 62 (hostname mismatch)
# List all SANs:
openssl s_client -connect api.example.com:443 2>/dev/null \
| openssl x509 -noout -text | grep -A1 'Subject Alternative Name'
```
**Step 3 — Check TLS version compatibility**
```bash
# Test supported TLS versions:
openssl s_client -connect api.example.com:443 -tls1_2
openssl s_client -connect api.example.com:443 -tls1_3
# 'CONNECTED' = that TLS version is accepted; 'handshake failure' = rejected
# Check Nginx TLS configuration:
grep -E 'ssl_protocols|ssl_ciphers' /etc/nginx/sites-available/yoursite
```
**Step 4 — Identify mixed content (ws:// on HTTPS page)**
```bash
# In the browser DevTools Console, search for:
# 'Mixed Content: The page was loaded over HTTPS, but attempted to connect to the insecure WebSocket'
# Fix: always use wss:// when the main page is served over HTTPS
# Dynamically choose the scheme:
const protocol = location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(protocol + 'api.example.com/ws');
```
**Step 5 — Check certificate expiry**
```bash
openssl s_client -connect api.example.com:443 2>/dev/null \
| openssl x509 -noout -dates
# notAfter=Jan 1 00:00:00 2025 GMT → check if this is in the past
```
```javascript
// Try both ws:// and wss://:
const wsInsecure = new WebSocket('ws://api.example.com/ws');
const wsSecure = new WebSocket('wss://api.example.com/ws');
wsSecure.onerror = (event) => {
console.error('wss failed:', event);
};
// If wsInsecure connects but wsSecure fails → TLS issue confirmed
```
**Step 2 — Inspect the certificate with openssl**
```bash
# Connect to the WebSocket server's TLS port and show certificate details:
openssl s_client -connect api.example.com:443 -servername api.example.com
# Look for:
# CN (common name) and SAN (Subject Alternative Names)
# Verify return code: 0 (ok) or error code 62 (hostname mismatch)
# List all SANs:
openssl s_client -connect api.example.com:443 2>/dev/null \
| openssl x509 -noout -text | grep -A1 'Subject Alternative Name'
```
**Step 3 — Check TLS version compatibility**
```bash
# Test supported TLS versions:
openssl s_client -connect api.example.com:443 -tls1_2
openssl s_client -connect api.example.com:443 -tls1_3
# 'CONNECTED' = that TLS version is accepted; 'handshake failure' = rejected
# Check Nginx TLS configuration:
grep -E 'ssl_protocols|ssl_ciphers' /etc/nginx/sites-available/yoursite
```
**Step 4 — Identify mixed content (ws:// on HTTPS page)**
```bash
# In the browser DevTools Console, search for:
# 'Mixed Content: The page was loaded over HTTPS, but attempted to connect to the insecure WebSocket'
# Fix: always use wss:// when the main page is served over HTTPS
# Dynamically choose the scheme:
const protocol = location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(protocol + 'api.example.com/ws');
```
**Step 5 — Check certificate expiry**
```bash
openssl s_client -connect api.example.com:443 2>/dev/null \
| openssl x509 -noout -dates
# notAfter=Jan 1 00:00:00 2025 GMT → check if this is in the past
```
Lösung
**Fix 1 — Issue a certificate that covers the WebSocket hostname**
```bash
# Certbot — include the WebSocket subdomain:
certbot certonly --nginx \
-d example.com \
-d www.example.com \
-d api.example.com \
-d ws.example.com
# Or use a wildcard certificate:
certbot certonly --dns-cloudflare \
-d '*.example.com' -d example.com
```
**Fix 2 — Configure Nginx to use the correct certificate**
```nginx
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location /ws/ {
proxy_pass http://127.0.0.1:8001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
}
}
```
**Fix 3 — Fix mixed content (force wss://)**
```javascript
// Always derive WebSocket scheme from page protocol:
const wsProto = location.protocol === 'https:' ? 'wss' : 'ws';
const socket = new WebSocket(`${wsProto}://api.example.com/ws`);
```
**Fix 4 — Trust self-signed cert in development only**
```bash
# Add the CA to system trust store (macOS):
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain /path/to/myCA.crt
# Node.js — pass the CA for internal services:
const ws = new WebSocket('wss://internal-api:8443', {
ca: fs.readFileSync('/path/to/myCA.crt')
});
```
```bash
# Certbot — include the WebSocket subdomain:
certbot certonly --nginx \
-d example.com \
-d www.example.com \
-d api.example.com \
-d ws.example.com
# Or use a wildcard certificate:
certbot certonly --dns-cloudflare \
-d '*.example.com' -d example.com
```
**Fix 2 — Configure Nginx to use the correct certificate**
```nginx
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location /ws/ {
proxy_pass http://127.0.0.1:8001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
}
}
```
**Fix 3 — Fix mixed content (force wss://)**
```javascript
// Always derive WebSocket scheme from page protocol:
const wsProto = location.protocol === 'https:' ? 'wss' : 'ws';
const socket = new WebSocket(`${wsProto}://api.example.com/ws`);
```
**Fix 4 — Trust self-signed cert in development only**
```bash
# Add the CA to system trust store (macOS):
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain /path/to/myCA.crt
# Node.js — pass the CA for internal services:
const ws = new WebSocket('wss://internal-api:8443', {
ca: fs.readFileSync('/path/to/myCA.crt')
});
```
Prävention
- Use wildcard certificates (*.example.com) to automatically cover all subdomains including WebSocket endpoints
- Set up automated certificate renewal (Certbot timer or ACM) and alert when expiry is within 30 days
- Always derive the WebSocket scheme (ws/wss) from the page's protocol at runtime
- Test wss:// connectivity from multiple browsers (Chrome, Firefox, Safari) before deploying
- Never use self-signed certificates in production; use Let's Encrypt or a trusted CA
- Set up automated certificate renewal (Certbot timer or ACM) and alert when expiry is within 30 days
- Always derive the WebSocket scheme (ws/wss) from the page's protocol at runtime
- Test wss:// connectivity from multiple browsers (Chrome, Firefox, Safari) before deploying
- Never use self-signed certificates in production; use Let's Encrypt or a trusted CA