← Back to Blog

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:

  1. Leaf certificate (depth 0): Your domain certificate. Issued to yourdomain.com by an intermediate CA.
  2. Intermediate certificate(s) (depth 1, 2, ...): Issued by the root CA to the intermediate CA. There can be multiple intermediate certificates forming a chain.
  3. 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_certificate file should contain only certificates, never the private key. The private key goes in ssl_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 Certificate

Fix 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 Certificate

Common Pitfalls

  • Testing only in your browser: Browsers cache intermediates. Always verify with openssl s_client or curl from 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.

UK
Written by Usman Khan
DevOps Engineer | MSc Cybersecurity | CEH | AWS Solutions Architect

Usman has 10+ years of experience securing enterprise infrastructure, managing high-traffic servers, and building zero-knowledge security tools. Read more about the author.