Serve a full certificate chain over TLS 1.3 and check it with openssl and curl
LinuxWorking
Tasks
- Confirm the leaf and the intermediate belong together and the leaf covers the right names.
- Build a chain file with the leaf first and the intermediate second.
- Configure nginx for TLS 1.2 and 1.3 only, HTTP/2 and HSTS, using the chain file.
- Prove from PC1 that the server sends both certificates and the chain verifies.
- Prove that HTTP/2 is negotiated with ALPN and measure DNS, TCP, TLS and first-byte timing.
Solution
1Inspect the certificatesWEB1
WEB1 on Linux cd /etc/ssl/shop openssl x509 -in shop.example.com.crt -noout -subject -issuer -dates -ext subjectAltName openssl x509 -in example-issuing-ca-g2.crt -noout -subject openssl verify -untrusted example-issuing-ca-g2.crt shop.example.com.crtThe leaf's issuer must equal the intermediate's subject.
openssl verifywith-untrusteduses the intermediate to build the path and the system store for the root; it printsshop.example.com.crt: OK.2Build the chain fileWEB1
WEB1 on Linux sudo sh -c 'cat shop.example.com.crt example-issuing-ca-g2.crt > fullchain.pem' grep -c 'BEGIN CERTIFICATE' fullchain.pemThe count must be 2. Order matters, leaf first. Do not add the root.
3Configure the server blockWEB1
WEB1 on Linux # /etc/nginx/sites-available/shop.conf server { listen 443 ssl; listen [::]:443 ssl; http2 on; server_name shop.example.com www.shop.example.com; ssl_certificate /etc/ssl/shop/fullchain.pem; ssl_certificate_key /etc/ssl/shop/shop.example.com.key; ssl_protocols TLSv1.2 TLSv1.3; add_header Strict-Transport-Security "max-age=31536000" always; root /var/www/shop; }http2 on;needs nginx 1.25.1 or later; older versions uselisten 443 ssl http2;. TLS 1.3 cipher suites are fixed by OpenSSL and need nossl_ciphersline.4Test and reloadWEB1
WEB1 on Linux sudo nginx -t sudo systemctl reload nginxA reload keeps existing connections; a failed
nginx -tmeans the reload would be refused.
Verify
PC1# openssl s_client -connect shop.example.com:443 -servername shop.example.com -alpn h2,http/1.1 </dev/null
CONNECTED(00000003)
depth=2 C=US, O=Example Trust, CN=Example Root CA
verify return:1
depth=1 C=US, O=Example Trust, CN=Example Issuing CA G2
verify return:1
depth=0 CN=shop.example.com
verify return:1
---
Certificate chain
0 s:CN=shop.example.com
i:C=US, O=Example Trust, CN=Example Issuing CA G2
a:PKEY: RSA, 2048 (bit); sigalg: ecdsa-with-SHA256
v:NotBefore: Nov 7 03:23:08 2026 GMT; NotAfter: Feb 5 03:23:08 2027 GMT
1 s:C=US, O=Example Trust, CN=Example Issuing CA G2
i:C=US, O=Example Trust, CN=Example Root CA
a:PKEY: EC, (prime256v1); sigalg: ecdsa-with-SHA256
v:NotBefore: Mar 2 10:00:00 2026 GMT; NotAfter: Mar 1 10:00:00 2031 GMT
---
...
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
...
ALPN protocol: h2
...
Verify return code: 0 (ok)Two certificates are sent, each i: matches the next s:, the path ends at the trusted root (depth 2),
TLS 1.3 is negotiated, ALPN selected h2, and the verify return code is 0.
PC1# curl -sv -o /dev/null https://shop.example.com/
* Trying 203.0.113.80:443...
* Connected to shop.example.com (203.0.113.80) port 443
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* CAfile: /etc/ssl/certs/ca-certificates.crt
* CApath: /etc/ssl/certs
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519 / RSASSA-PSS
* ALPN: server accepted h2
* Server certificate:
* subject: CN=shop.example.com
* start date: Nov 7 03:23:08 2026 GMT
* expire date: Feb 5 03:23:08 2027 GMT
* subjectAltName: host "shop.example.com" matched cert's "shop.example.com"
* issuer: C=US; O=Example Trust; CN=Example Issuing CA G2
* SSL certificate verify ok.
> GET / HTTP/2
> Host: shop.example.com
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/2 200
< server: nginx
< content-type: text/html
< strict-transport-security: max-age=31536000The TLS 1.3 handshake runs in order: the server's Certificate, CertificateVerify and Finished arrive in one flight after ServerHello. The client's Change cipher spec is the compatibility-mode dummy, not a real cipher change. The name matches the SAN, the chain verifies, ALPN chose h2, and the HSTS header is present.
PC1# curl -o /dev/null -s -w 'dns %{time_namelookup} connect %{time_connect} tls %{time_appconnect} ttfb %{time_starttransfer}\n' https://shop.example.com/
dns 0.004211 connect 0.031877 tls 0.064502 ttfb 0.097316The values are cumulative seconds. TCP connect took 27.7 ms (one round trip), the TLS 1.3 handshake another 32.6 ms (one round trip plus crypto), and the first byte 32.8 ms after that (one round trip plus server time). Three round trips before the first byte of content, as expected for TCP plus TLS 1.3.
Mistakes people make
- Pointing
ssl_certificateat the leaf-only file. Browsers may still work, but curl, Java, Python and most API clients fail with "unable to get local issuer certificate". - Putting the intermediate before the leaf. The server must send the leaf first.
- Testing with an IP address and no
-servername. Without SNI nginx may answer with its default certificate and you test the wrong thing. - Judging the chain from a browser. Browsers repair missing intermediates and hide the problem.
- Forgetting to reload nginx after
nginx -t. The test alone changes nothing.
On other platforms
Apache httpd 2.4.8 and later reads the chain from the same file as the certificate.
SSLEngine on
SSLCertificateFile /etc/ssl/shop/fullchain.pem
SSLCertificateKeyFile /etc/ssl/shop/shop.example.com.key
SSLProtocol -all +TLSv1.2 +TLSv1.3Say it in the interview
"I check that the leaf's issuer is the intermediate's subject, build a chain file with the leaf first, and point nginx at it with only TLS 1.2 and 1.3 enabled. Then I verify from a client with s_client and SNI: two certificates, each issuer matching the next subject, verify return code 0, ALPN h2. curl -v confirms the handshake and the SAN match, and curl -w splits the time into DNS, TCP, TLS and first byte."