Fix Incomplete SSL Certificate Chain Error on Nginx, Apache, and AWS
Quick Fix for Panicking Engineers
Your API clients or mobile apps are getting SSL errors but the site works in browsers. Fix in 2 minutes:
# 1. Diagnose: check how many certs your server sends
openssl s_client -connect yourdomain.com:443 -servername yourdomain.com < /dev/null 2>&1 | grep -E 'depth|verify'
# If you see only depth=0, the chain is incomplete
# 2. Download the correct intermediate certificate
# For Let's Encrypt:
curl -o intermediate.pem https://letsencrypt.org/certs/lets-encrypt-r3.pem
# For other CAs: check your CA's documentation or use https://whatsmychaincert.com
# 3. Create the bundle (Nginx)
cat /etc/ssl/yourdomain.crt intermediate.pem > /etc/ssl/yourdomain-bundle.crt
# Update nginx: ssl_certificate /etc/ssl/yourdomain-bundle.crt;
sudo nginx -t && sudo systemctl reload nginx
Your site loads fine in Chrome and Firefox. But curl returns SSL certificate problem: unable to get local issuer certificate. Your mobile app crashes with a TLS handshake failure. An API partner reports they cannot connect. The cause is always the same: your server is sending the leaf certificate without the intermediate certificates that chain it to a trusted root CA. Browsers work around this with AIA fetching and cached intermediates. Everything else fails. Here is how to fix it on every platform.
Diagnosing the Problem
Method 1: openssl s_client
# Connect and display the certificate chain
openssl s_client -connect yourdomain.com:443 -servername yourdomain.com < /dev/null 2>&1
# What a BROKEN chain looks like:
# ---
# Certificate chain
# 0 s:CN = yourdomain.com
# i:C = US, O = Let's Encrypt, CN = R3
# ---
# Verify return code: 21 (unable to verify the first certificate)
# What a CORRECT chain looks like:
# ---
# Certificate chain
# 0 s:CN = yourdomain.com
# i:C = US, O = Let's Encrypt, CN = R3
# 1 s:C = US, O = Let's Encrypt, CN = R3
# i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
# ---
# Verify return code: 0 (ok)
Method 2: SSL Labs
# Use the Qualys SSL Labs test
# https://www.ssllabs.com/ssltest/analyze.html?d=yourdomain.com
# Look for:
# "Chain issues: Incomplete"
# "Extra download: [intermediate cert name]"
# Grade will typically be capped at B with an incomplete chain
Method 3: curl
# curl will fail with an incomplete chain (unlike browsers)
curl -v https://yourdomain.com 2>&1 | grep -E 'SSL|certificate'
# Broken output:
# * SSL certificate problem: unable to get local issuer certificate
# curl: (60) SSL certificate problem: unable to get local issuer certificate
# Working output:
# * SSL certificate verify ok.
# * SSL connection using TLSv1.3
Understanding Certificate Chain Order
A certificate chain has three layers:
- Leaf certificate (depth 0): Your domain certificate. Issued to yourdomain.com by an intermediate CA.
- Intermediate certificate(s) (depth 1, 2, ...): Issued by the root CA to the intermediate CA. There can be multiple intermediate certificates forming a chain.
- Root certificate (highest depth): Self-signed by a trusted Certificate Authority. Pre-installed in operating systems and browsers. You do NOT need to include this in your bundle.
Your server must send the leaf certificate AND all intermediate certificates. The client's trust store provides the root certificate.
# Correct bundle file contents (in this exact order):
# -----BEGIN CERTIFICATE-----
# [Your domain certificate / leaf cert]
# -----END CERTIFICATE-----
# -----BEGIN CERTIFICATE-----
# [Intermediate certificate 1]
# -----END CERTIFICATE-----
# -----BEGIN CERTIFICATE-----
# [Intermediate certificate 2, if applicable]
# -----END CERTIFICATE-----
# DO NOT include the root certificate
# DO NOT reverse the order (leaf must be first)
Fix for Nginx
Nginx uses a single ssl_certificate directive that must contain the full chain (leaf + intermediates concatenated into one file).
# Step 1: Identify your certificate files
ls -la /etc/ssl/
# You should have:
# - yourdomain.crt (leaf certificate)
# - intermediate.crt or ca-bundle.crt (intermediate certificate)
# - yourdomain.key (private key)
# Step 2: Create the bundle file
cat /etc/ssl/yourdomain.crt /etc/ssl/intermediate.crt > /etc/ssl/yourdomain-bundle.crt
# Step 3: Verify the bundle order
openssl crl2pkcs7 -nocrl -certfile /etc/ssl/yourdomain-bundle.crt | \
openssl pkcs7 -print_certs -noout
# Should show leaf cert first, then intermediate(s)
# Step 4: Update Nginx config
# /etc/nginx/sites-available/yourdomain.conf
server {
listen 443 ssl http2;
server_name yourdomain.com;
ssl_certificate /etc/ssl/yourdomain-bundle.crt; # Full chain
ssl_certificate_key /etc/ssl/yourdomain.key; # Private key only
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;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_stapling on;
ssl_stapling_verify on;
}
# Step 5: Test and reload
sudo nginx -t && sudo systemctl reload nginx
Common Nginx Mistakes
- Wrong order in bundle: If you run
cat intermediate.crt yourdomain.crt(intermediate first), Nginx will fail silently or serve an invalid chain. Leaf certificate must be first. - Missing newline between certs: When concatenating, ensure there is a newline between the
-----END CERTIFICATE-----of one cert and the-----BEGIN CERTIFICATE-----of the next. Some text editors strip trailing newlines. - Including the private key in the bundle: The
ssl_certificatefile should contain only certificates, never the private key. The private key goes inssl_certificate_key.
Fix for Apache
Apache 2.4.8+ (Recommended)
# Apache 2.4.8+ supports the same bundle approach as Nginx
# Create the bundle:
cat /etc/ssl/yourdomain.crt /etc/ssl/intermediate.crt > /etc/ssl/yourdomain-bundle.crt
# VirtualHost config:
<VirtualHost *:443>
ServerName yourdomain.com
SSLEngine on
SSLCertificateFile /etc/ssl/yourdomain-bundle.crt
SSLCertificateKeyFile /etc/ssl/yourdomain.key
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256
SSLHonorCipherOrder off
</VirtualHost>
Apache 2.4.7 and Earlier
# Older Apache uses a separate directive for the chain
<VirtualHost *:443>
ServerName yourdomain.com
SSLEngine on
SSLCertificateFile /etc/ssl/yourdomain.crt
SSLCertificateKeyFile /etc/ssl/yourdomain.key
SSLCertificateChainFile /etc/ssl/intermediate.crt
# Note: SSLCertificateChainFile is deprecated in 2.4.8+
# Use the bundle approach instead
</VirtualHost>
# Test and restart
sudo apachectl configtest && sudo systemctl restart apache2
Fix for AWS ALB / CloudFront
AWS Certificate Manager (ACM)
If you use ACM certificates, the chain is handled automatically. You never see this error with ACM-issued certificates on ALB or CloudFront. The problem only occurs with imported certificates.
Importing a Certificate with Full Chain to ACM
# When importing to ACM, you must provide the chain separately
aws acm import-certificate \
--certificate fileb://yourdomain.crt \
--private-key fileb://yourdomain.key \
--certificate-chain fileb://intermediate.crt \
--region us-east-1
# The --certificate-chain parameter must contain ALL intermediate certificates
# Do NOT include the root certificate
# Do NOT include the leaf certificate (that goes in --certificate)
AWS ALB with Imported Certificate
# If you already imported without the chain, re-import with the chain
# ACM will replace the certificate in-place
# List existing certificates
aws acm list-certificates --region us-east-1
# Re-import with chain (use the same ARN)
aws acm import-certificate \
--certificate-arn arn:aws:acm:us-east-1:123456789:certificate/abc-123 \
--certificate fileb://yourdomain.crt \
--private-key fileb://yourdomain.key \
--certificate-chain fileb://intermediate.crt
# The ALB picks up the new certificate automatically (no restart needed)
CloudFront with Custom SSL Certificate
# CloudFront requires the certificate in us-east-1
# The same ACM import applies, but in us-east-1 specifically
aws acm import-certificate \
--certificate fileb://yourdomain.crt \
--private-key fileb://yourdomain.key \
--certificate-chain fileb://intermediate.crt \
--region us-east-1
# Then update the CloudFront distribution to use this certificate
aws cloudfront update-distribution \
--id E1234567890 \
--viewer-certificate ACMCertificateArn=arn:aws:acm:us-east-1:123456789:certificate/abc-123,SSLSupportMethod=sni-only,MinimumProtocolVersion=TLSv1.2_2021
Check Your SSL Certificate
Use SecureBin's SSL Checker to verify your certificate chain, expiry date, and TLS configuration in one click.
Check SSL CertificateFix for Let's Encrypt
Let's Encrypt certificates issued by Certbot include the full chain by default. The problem occurs when you use the wrong file.
# Certbot creates these files:
# /etc/letsencrypt/live/yourdomain.com/
# cert.pem - Leaf certificate only (DO NOT use this for ssl_certificate)
# chain.pem - Intermediate certificate(s) only
# fullchain.pem - Leaf + intermediates (USE THIS for ssl_certificate)
# privkey.pem - Private key
# CORRECT Nginx config:
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
# WRONG (causes incomplete chain):
ssl_certificate /etc/letsencrypt/live/yourdomain.com/cert.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
# CORRECT Apache config:
SSLCertificateFile /etc/letsencrypt/live/yourdomain.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/yourdomain.com/privkey.pem
Let's Encrypt Cross-Sign Expiry Issue
In 2021, the DST Root CA X3 cross-signed root expired. If your server sends the old cross-signed chain, older clients (Android 7 and below) fail. Modern Certbot handles this correctly, but if you have a stale chain:
# Force renewal with the correct chain
sudo certbot renew --force-renewal
# Verify the chain uses ISRG Root X1
openssl s_client -connect yourdomain.com:443 -servername yourdomain.com < /dev/null 2>&1 | grep 'ISRG'
# Should show: O = Internet Security Research Group, CN = ISRG Root X1
Fix for HAProxy
# HAProxy expects a single PEM file with: cert + intermediates + private key
cat /etc/ssl/yourdomain.crt /etc/ssl/intermediate.crt /etc/ssl/yourdomain.key > /etc/ssl/haproxy-bundle.pem
# haproxy.cfg
frontend https-in
bind *:443 ssl crt /etc/ssl/haproxy-bundle.pem alpn h2,http/1.1
default_backend servers
# Test and reload
sudo haproxy -c -f /etc/haproxy/haproxy.cfg && sudo systemctl reload haproxy
Verify the Fix
# 1. openssl verification (most reliable)
openssl s_client -connect yourdomain.com:443 -servername yourdomain.com < /dev/null 2>&1 | grep 'Verify return code'
# Expected: Verify return code: 0 (ok)
# 2. Count certificates in the chain
openssl s_client -connect yourdomain.com:443 -servername yourdomain.com -showcerts < /dev/null 2>&1 | grep -c 'BEGIN CERTIFICATE'
# Should be 2 or 3 (leaf + 1-2 intermediates)
# 3. curl verification
curl -sI https://yourdomain.com | head -1
# Should return "HTTP/2 200" (or your expected status code)
# 4. Test from a different network/machine
# The fix might appear to work locally due to cached intermediates
ssh remote-server "curl -sI https://yourdomain.com | head -1"
# 5. SSL Labs grade
# https://www.ssllabs.com/ssltest/analyze.html?d=yourdomain.com
# Should show "A" or "A+" with no chain issues
Automating Chain Validation
# Add to your monitoring/deployment pipeline
#!/bin/bash
DOMAIN="yourdomain.com"
RESULT=$(openssl s_client -connect "$DOMAIN:443" -servername "$DOMAIN" < /dev/null 2>&1 | grep 'Verify return code')
if echo "$RESULT" | grep -q "0 (ok)"; then
echo "SSL chain OK for $DOMAIN"
else
echo "SSL CHAIN ERROR for $DOMAIN: $RESULT"
# Send alert
curl -X POST https://hooks.slack.com/services/YOUR/WEBHOOK \
-H 'Content-Type: application/json' \
-d "{\"text\":\"SSL chain incomplete on $DOMAIN: $RESULT\"}"
exit 1
fi
Decode Your Certificate
Use SecureBin's Certificate Decoder to inspect certificate details, chain order, and expiry dates before deploying.
Decode CertificateCommon Pitfalls
- Testing only in your browser: Browsers cache intermediates. Always verify with
openssl s_clientorcurlfrom a clean environment. - Including the root CA: Including the root certificate wastes bandwidth and can cause issues with some clients. The root is already in the client's trust store.
- Forgetting to reload after changes: Nginx and Apache require a reload (not just a config test) to pick up new certificate files.
- File permissions: Certificate files should be readable by the web server user (typically root:root with 644 permissions). Private keys should be 600.
- Stale cached certificate in CDN: If you use a CDN (Cloudflare, CloudFront), the CDN may cache the old certificate. Purge the CDN cache or wait for the TTL to expire.
Summary
An incomplete SSL certificate chain means your server sends only the leaf certificate without the intermediate certificates. Browsers hide this problem by fetching intermediates automatically. Everything else (curl, mobile apps, API clients, other servers) fails. The fix is the same on every platform: concatenate your leaf certificate with the intermediate certificate(s) in the correct order (leaf first) and configure your server to serve the bundle. On Nginx, use ssl_certificate pointing to the bundle. On Apache 2.4.8+, use SSLCertificateFile with the bundle. On AWS, import with --certificate-chain. For Let's Encrypt, use fullchain.pem instead of cert.pem. Verify with openssl s_client, never with a browser.
Related Articles
Continue reading: Fix CORS No Access-Control-Allow-Origin, Fix Cookie Without HttpOnly Flag, Fix Nginx 502 Bad Gateway with PHP-FPM.
Usman has 10+ years of experience securing enterprise infrastructure, managing high-traffic servers, and building zero-knowledge security tools. Read more about the author.