Skip to content

Split View: TLS 핸드셰이크와 인증서 오류 해독 — 브라우저는 되는데 curl은 실패하는 이유

✨ Learn with Quiz
|

TLS 핸드셰이크와 인증서 오류 해독 — 브라우저는 되는데 curl은 실패하는 이유

들어가며 — 브라우저는 자물쇠가 보이는데 curl은 실패합니다

새로 발급한 인증서를 배포했습니다. 브라우저로 열어 보니 자물쇠가 정상이고 인증서 정보도 문제가 없습니다. 그런데 백엔드에서 그 엔드포인트를 호출하면 이렇게 실패합니다.

curl -sS https://api.example.com/v1/health
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.html

이 시점에 가장 흔한 결론은 "curl이 최신 CA를 모른다"입니다. 그래서 ca-certificates를 업데이트하고, 그래도 안 되면 -k를 붙이거나 애플리케이션에서 검증을 끕니다. 이것은 원인을 덮는 조치이고, 대개 서버 설정이 잘못된 상태로 운영에 들어가게 만듭니다.

진짜 원인은 거의 항상 하나입니다. 서버가 중간 인증서를 함께 보내지 않고 있고, 브라우저는 그것을 스스로 메워서 성공한 것입니다. 이 글은 그 메커니즘을 정확히 설명하고, 오류 메시지만 보고 원인을 지목할 수 있게 정리합니다.

TLS 1.3 핸드셰이크를 메시지 순서로 읽기

디버깅을 하려면 어떤 메시지가 어떤 순서로 오가는지 알아야 합니다. TLS 1.3은 왕복 한 번에 끝납니다.

클라이언트                                              서버
  |                                                      |
  |-- ClientHello ------------------------------------->  |
  |     supported_versions: TLS 1.3                       |
  |     key_share: x25519 공개키                          |
  |     server_name: api.example.com   (SNI, 평문)        |
  |     application_layer_protocol_negotiation: h2, http/1.1
  |                                                      |
  |  <------------------------------------ ServerHello -- |
  |     key_share: x25519 공개키                          |
  |     ---- 여기부터 모든 메시지가 암호화된다 ----        |
  |  <------------------------------- EncryptedExtensions |
  |  <----------------------------- CertificateRequest(*) |
  |  <------------------------------------- Certificate   |
  |  <------------------------------- CertificateVerify   |
  |  <------------------------------------------ Finished |
  |                                                      |
  |-- Certificate(*) ---------------------------------->  |
  |-- CertificateVerify(*) ---------------------------->  |
  |-- Finished ---------------------------------------->  |
  |-- Application Data (첫 HTTP 요청) ----------------->  |
  |                                                      |
  (*)는 mTLS를 요구할 때만 등장

여기서 운영에 직접 영향을 주는 사실이 세 가지 나옵니다.

첫째, ServerHello 이후의 모든 메시지가 암호화됩니다. TLS 1.2에서는 패킷 캡처만으로 서버 인증서를 꺼내 볼 수 있었지만 TLS 1.3에서는 불가능합니다. 와이어샤크에서 인증서가 안 보인다고 서버가 인증서를 안 보낸 것이 아닙니다. 인증서를 확인하려면 반드시 클라이언트 쪽에서, 즉 openssl s_client 같은 도구로 확인해야 합니다.

둘째, ClientHello의 SNI는 여전히 평문입니다. 나머지가 다 암호화되어도 접속 대상 호스트 이름은 노출됩니다. 이것이 Encrypted Client Hello가 등장한 이유이기도 합니다. 디버깅 관점에서는 오히려 유용합니다. 패킷 캡처에서 SNI만은 언제나 읽을 수 있습니다.

셋째, 클라이언트가 Finished와 첫 요청을 연달아 보냅니다. 서버가 클라이언트 인증서를 검증하기 전에 클라이언트는 이미 핸드셰이크가 끝났다고 판단합니다. 그래서 mTLS 인증 실패는 핸드셰이크 오류가 아니라 첫 요청을 보낸 직후의 연결 끊김으로 나타납니다. 이 차이가 mTLS 디버깅을 어렵게 만드는 핵심입니다.

SNI가 없으면 서버는 아무 인증서나 내밉니다

하나의 IP와 포트에 수백 개 사이트가 붙어 있는 것이 요즘 기본 구성입니다. 서버는 TCP 연결만으로는 어느 인증서를 내밀지 알 수 없습니다. 그 정보를 ClientHello의 SNI 확장이 알려줍니다.

SNI 없이 접속하면 서버는 기본 가상 호스트의 인증서를 돌려줍니다.

# SNI를 빼고 IP로 직접 접속
openssl s_client -connect 203.0.113.40:443 -noservername </dev/null 2>/dev/null \
  | openssl x509 -noout -subject
subject=CN = default.vhost.example.net
# SNI를 명시하면 올바른 인증서가 나온다
openssl s_client -connect 203.0.113.40:443 -servername api.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject
subject=CN = api.example.com

openssl s_client에서 가장 자주 나오는 실수가 여기 있습니다. -connect만 쓰고 -servername을 빼면 SNI가 전송되지 않아, 실제 서비스와 다른 인증서를 보고 엉뚱한 결론을 내리게 됩니다. 최신 OpenSSL은 -connect의 호스트 이름을 SNI로 자동 설정하지만, IP로 접속하거나 구버전을 쓸 때는 그렇지 않습니다. -servername은 항상 명시하는 습관이 안전합니다.

SNI를 보내지 않는 클라이언트가 실제로 존재합니다. 아주 오래된 자바 클라이언트, 일부 임베디드 기기, 그리고 일부 모니터링 에이전트가 그렇습니다. 이런 클라이언트만 인증서 오류를 내는 상황이라면 원인은 인증서가 아니라 SNI 미전송입니다. 서버 쪽 기본 가상 호스트를 무엇으로 둘 것인지가 곧 대응책이 됩니다.

캡처에서 SNI를 확인하는 방법도 알아 두면 좋습니다.

sudo tcpdump -nn -i any -A 'tcp port 443 and tcp[((tcp[12:1] & 0xf0) >> 2)] = 0x16' | head -40

체인 검증과 중간 인증서 누락 — 브라우저와 curl이 갈리는 지점

검증은 종단 인증서에서 시작해 발급자를 따라 올라가면서, 로컬 신뢰 저장소에 있는 루트에 도달하면 성공합니다. 서버의 책임은 루트를 제외한 중간 인증서를 모두 함께 보내는 것입니다.

정상적인 체인은 이렇게 보입니다.

openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
  | sed -n '/Certificate chain/,/---/p'
Certificate chain
 0 s:CN = api.example.com
   i:C = US, O = Let's Encrypt, CN = R11
 1 s:C = US, O = Let's Encrypt, CN = R11
   i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
---

중간 인증서가 빠진 서버는 이렇게 나옵니다.

Certificate chain
 0 s:CN = api.example.com
   i:C = US, O = Let's Encrypt, CN = R11
---
...
Verify return code: 20 (unable to get local issuer certificate)

0번만 있고 1번이 없습니다. 클라이언트는 R11이라는 발급자를 신뢰 저장소에서 찾아야 하는데, 신뢰 저장소에는 루트인 ISRG Root X1만 있고 중간 인증서 R11은 없습니다. 그래서 사슬이 끊깁니다.

그런데 브라우저는 왜 성공할까요. 여기가 핵심입니다.

종단 인증서 안에는 발급자 인증서를 내려받을 수 있는 URL이 들어 있습니다.

openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -text | grep -A3 'Authority Information Access'
            Authority Information Access:
                OCSP - URI:http://r11.o.lencr.org
                CA Issuers - URI:http://r11.i.lencr.org/

크롬, 사파리, 엣지는 검증 중 발급자를 못 찾으면 이 CA Issuers URL로 HTTP 요청을 보내 중간 인증서를 직접 내려받습니다. 이것을 AIA fetching 또는 AIA chasing이라고 합니다. 파이어폭스는 조금 다른 방식으로, 알려진 중간 인증서를 미리 내장하고 한 번 본 중간 인증서를 캐시해 둡니다.

반면 OpenSSL은 기본적으로 AIA를 따라가지 않습니다. curl, wget, 파이썬 requests, Go, Node 대부분이 여기에 해당합니다. 자바도 기본은 따라가지 않고 com.sun.security.enableAIAcaIssuers 속성을 켜야 합니다.

그래서 다음 명제가 성립합니다. 브라우저에서 되는데 curl에서 실패한다면 클라이언트가 아니라 서버가 잘못된 것입니다. 브라우저는 서버의 실수를 대신 메워 주고 있을 뿐입니다. 게다가 AIA fetching은 네트워크 요청이므로 첫 접속마다 수십에서 수백 밀리초를 더 씁니다. 사용자 체감 지연으로도 이어집니다.

확인은 두 명령이면 끝납니다.

# 서버가 보내는 체인만으로 검증되는지 확인 (신뢰 저장소는 시스템 기본)
openssl s_client -connect api.example.com:443 -servername api.example.com \
  -verify_return_error </dev/null 2>&1 | tail -3

# 체인을 파일로 떠서 개수 확인
openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null 2>/dev/null \
  | grep -c 'BEGIN CERTIFICATE'
Verify return code: 0 (ok)
2

고치는 방법은 서버 설정입니다. nginx의 ssl_certificate에는 종단 인증서와 중간 인증서를 순서대로 이어 붙인 파일(fullchain)을 지정해야 합니다. 아파치는 최신 버전에서 SSLCertificateFile에 fullchain을 넣으면 되고, 구버전은 SSLCertificateChainFile을 따로 지정합니다. 로드밸런서라면 인증서 등록 화면의 체인 항목을 비워 두지 않았는지 확인합니다.

오류 메시지 사전

메시지만 정확히 읽어도 원인의 대부분이 결정됩니다.

오류 메시지실제 원인확인 명령조치
unable to get local issuer certificate서버가 중간 인증서를 안 보냈거나 로컬에 루트가 없음s_client 체인 개수 확인, 신뢰 저장소 존재 확인서버에 fullchain 배포, 또는 ca-certificates 설치
certificate has expirednotAfter 경과, 또는 클라이언트 시계가 어긋남openssl x509 -noout -dates, date 확인갱신, 또는 NTP 동기화
certificate is not yet validnotBefore 이전, 사실상 클라이언트 시계 문제date, timedatectl시각 동기화
hostname mismatch, no alternative subject name접속한 이름이 인증서 SAN 목록에 없음openssl x509 -noout -ext subjectAltNameSAN에 이름 추가, 또는 올바른 SNI로 접속
self signed certificate in certificate chain사내 CA 또는 가로채기 프록시가 중간에 있음체인 최상단 발급자 확인사내 루트를 신뢰 저장소에 등록
self signed certificate종단 인증서 자체가 자체 서명issuer와 subject가 같은지 확인정식 인증서 발급
tlsv1 alert protocol version클라이언트와 서버의 최소 프로토콜 버전이 겹치지 않음s_client에 -tls1_2, -tls1_3을 각각 지정해 비교서버 설정 조정 또는 클라이언트 갱신
sslv3 alert handshake failure공통 암호군 없음, 또는 mTLS에서 클라이언트 인증서 미제출openssl ciphers, s_client에 -cert와 -key 지정암호군 정렬 또는 클라이언트 인증서 제시
certificate required (alert 116)TLS 1.3 mTLS에서 클라이언트가 빈 인증서를 보냄서버 로그, s_client에 -cert와 -key 지정클라이언트 인증서 설정

두 가지 오해를 정정합니다.

hostname mismatch에서 현대 클라이언트는 CN 필드를 아예 보지 않고 subjectAltName만 검사합니다. CN에 도메인을 넣어 두었으니 괜찮다는 판단은 2017년 이후로는 틀렸습니다. 크롬은 58 버전부터 CN 폴백을 제거했고 OpenSSL과 Go도 마찬가지입니다. 반드시 SAN을 확인해야 합니다.

openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -ext subjectAltName
X509v3 Subject Alternative Name:
    DNS:api.example.com, DNS:api-internal.example.com

certificate has expired도 자주 오진됩니다. 서버 인증서가 아니라 클라이언트 시계가 틀린 경우가 상당히 많습니다. 시계 없는 임베디드 기기, 오래 정지했다 부팅한 VM, 호스트 시각과 어긋난 컨테이너가 대표적입니다. 만료일과 현재 시각을 함께 확인하는 습관이 시간을 아껴 줍니다.

openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -dates
date -u
notBefore=Jun  2 08:14:31 2026 GMT
notAfter=Aug 31 08:14:30 2026 GMT
Sun Jul 26 05:11:20 UTC 2026

openssl s_client로 확인하는 순서

한 번에 다 보는 명령을 외워 두면 편합니다.

HOST=api.example.com
PORT=443

openssl s_client -connect "${HOST}:${PORT}" -servername "${HOST}" \
  -showcerts -status -alpn h2,http/1.1 </dev/null 2>&1 | head -60

주요 출력은 이렇게 읽습니다.

CONNECTED(00000003)
depth=2 C = US, O = Internet Security Research Group, CN = ISRG Root X1
verify return:1
depth=1 C = US, O = Let's Encrypt, CN = R11
verify return:1
depth=0 CN = api.example.com
verify return:1
---
Certificate chain
 0 s:CN = api.example.com
   i:C = US, O = Let's Encrypt, CN = R11
 1 s:C = US, O = Let's Encrypt, CN = R11
   i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
---
ALPN protocol: h2
OCSP response: no response sent
---
New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
Verify return code: 0 (ok)

Verify return code: 0 (ok)가 최종 판정입니다. depth가 높은 쪽부터 내려오는 verify 줄은 어느 단계에서 끊겼는지를 알려줍니다.

프로토콜별로 되는지 갈라 보는 것도 유용합니다.

openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_3 </dev/null 2>&1 | grep -E 'New,|Cipher'
openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_2 </dev/null 2>&1 | grep -E 'New,|Cipher'
New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
New, TLSv1.2, Cipher is ECDHE-RSA-AES128-GCM-SHA256

메일이나 데이터베이스처럼 STARTTLS를 쓰는 프로토콜은 옵션을 붙여야 합니다.

openssl s_client -connect smtp.example.com:587 -starttls smtp -servername smtp.example.com </dev/null
openssl s_client -connect db.example.com:5432 -starttls postgres </dev/null

curl로 볼 때는 다음 조합이 가장 정보량이 많습니다.

curl -v --resolve api.example.com:443:203.0.113.40 https://api.example.com/v1/health 2>&1 | grep -E '^\*'
* Server certificate:
*  subject: CN=api.example.com
*  start date: Jun  2 08:14:31 2026 GMT
*  expire date: Aug 31 08:14:30 2026 GMT
*  subjectAltName: host "api.example.com" matched cert's "api.example.com"
*  issuer: C=US; O=Let's Encrypt; CN=R11
*  SSL certificate verify ok.

--resolve는 DNS를 우회해 특정 IP로 접속하면서 SNI와 Host 헤더는 원래 이름으로 유지합니다. 로드밸런서 뒤의 개별 노드를 하나씩 검증할 때 필수입니다.

신뢰 저장소, 컨테이너, 그리고 mTLS의 실패 지점

검증이 실패하는 또 다른 축은 클라이언트 쪽 신뢰 저장소입니다. 배포판마다 경로와 갱신 명령이 다릅니다.

# 데비안, 우분투
ls -l /etc/ssl/certs/ca-certificates.crt
cp corp-root.crt /usr/local/share/ca-certificates/corp-root.crt
update-ca-certificates

# RHEL, 로키, 페도라
ls -l /etc/pki/tls/certs/ca-bundle.crt
cp corp-root.crt /etc/pki/ca-trust/source/anchors/
update-ca-trust extract

# 알파인
apk add --no-cache ca-certificates
cp corp-root.crt /usr/local/share/ca-certificates/
update-ca-certificates

컨테이너에서 가장 자주 나오는 사고는 최소 이미지에 신뢰 저장소가 아예 없는 경우입니다. Go 바이너리를 scratchalpine 위에 올리면 이렇게 실패합니다.

x509: certificate signed by unknown authority

인증서가 잘못된 것이 아니라 비교할 루트가 이미지 안에 하나도 없는 것입니다. 알파인이라면 ca-certificates 패키지를 설치하고, 정적 바이너리라면 distroless의 base 이미지를 쓰거나 멀티스테이지 빌드에서 인증서 묶음을 복사합니다.

FROM alpine:3.20 AS certs
RUN apk add --no-cache ca-certificates

FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY app /app
ENTRYPOINT ["/app"]

런타임마다 신뢰 저장소를 가리키는 환경 변수가 다르다는 점도 기억할 만합니다.

export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt   # OpenSSL, Go, curl
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt  # 파이썬 requests
export NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/corp-root.crt  # Node
# 자바는 별도 키스토어를 쓴다
keytool -importcert -alias corp-root -file corp-root.crt \
  -keystore "$JAVA_HOME/lib/security/cacerts" -storepass changeit -noprompt

self signed certificate in certificate chain 오류가 사내망에서만 난다면 원인은 대개 가로채기 프록시입니다. 회사가 TLS 검사 장비를 쓰면 모든 인증서가 사내 CA로 재서명되어 나옵니다. 체인 최상단 발급자를 보면 바로 드러납니다.

mTLS는 실패 지점이 두 배로 늘어납니다. 확인 순서는 이렇습니다.

# 서버가 클라이언트 인증서를 요구하는지, 그리고 어떤 CA를 받아 주는지 확인
openssl s_client -connect mtls.example.com:8443 -servername mtls.example.com </dev/null 2>&1 \
  | sed -n '/Acceptable client certificate CA names/,/^---/p'
Acceptable client certificate CA names
C = KR, O = Example Internal CA, CN = Example Issuing CA
Client Certificate Types: RSA sign, ECDSA sign

이 목록이 결정적입니다. 내 클라이언트 인증서의 발급자가 여기에 없으면 그 인증서로는 절대 통과하지 못합니다. 발급자를 확인합니다.

openssl x509 -in client.crt -noout -issuer -subject -dates
openssl verify -CAfile ca.crt client.crt
issuer=C = KR, O = Example Internal CA, CN = Example Issuing CA
subject=CN = payments-service
notBefore=Jul  1 00:00:00 2026 GMT
notAfter=Jul  1 00:00:00 2027 GMT
client.crt: OK

이제 클라이언트 인증서를 제시하고 접속합니다.

openssl s_client -connect mtls.example.com:8443 -servername mtls.example.com \
  -cert client.crt -key client.key -CAfile ca.crt </dev/null 2>&1 | tail -5

curl -sS --cert client.crt --key client.key --cacert ca.crt \
  https://mtls.example.com:8443/v1/health

앞에서 말한 TLS 1.3의 특성이 여기서 드러납니다. 클라이언트 인증서가 거부되어도 핸드셰이크는 성공한 것처럼 보이고, 오류는 첫 요청을 보낸 뒤에 나타납니다.

curl: (56) OpenSSL SSL_read: error:0A000412:SSL routines::sslv3 alert bad certificate, errno 0

클라이언트 로그만 보면 요청 전송 중 끊긴 것처럼 보이므로, mTLS 실패는 반드시 서버 로그와 함께 봐야 합니다. 확장 키 용도가 맞는지도 자주 놓치는 항목입니다. 서버 인증서에는 serverAuth, 클라이언트 인증서에는 clientAuth가 있어야 합니다.

openssl x509 -in client.crt -noout -ext extendedKeyUsage
X509v3 Extended Key Usage:
    TLS Web Client Authentication

마치며 — 클라이언트를 고치기 전에 체인을 세어 보십시오

인증서 문제의 판단 순서는 짧게 요약됩니다.

먼저 openssl s_client-servername을 반드시 붙여 접속하고, 출력의 Certificate chain에 몇 개가 나오는지 셉니다. 종단 인증서 하나만 나오면 그 순간 원인은 확정입니다. 서버가 중간 인증서를 안 보내고 있고, 브라우저가 AIA로 그것을 메워 주고 있었던 것입니다. 클라이언트를 손대는 대신 서버에 fullchain을 배포하면 끝납니다.

체인이 온전한데도 실패한다면 그때가 신뢰 저장소를 볼 차례입니다. 컨테이너에 ca-certificates가 있는지, 사내 CA가 등록되어 있는지, 런타임별 환경 변수가 맞는지 확인합니다.

그리고 검증을 끄는 선택지는 마지막까지 남겨 두어야 합니다. -kverify=False는 문제를 해결하지 않고 보이지 않게 만들 뿐이며, 그 상태로 배포되면 가로채기 공격에 그대로 노출됩니다. 대부분의 인증서 오류는 서버 설정 한 줄로 정확히 해결됩니다.

Decoding the TLS handshake and certificate errors — why the browser works but curl fails

Introduction — the browser shows a padlock but curl fails

You deployed a newly issued certificate. Opening it in a browser shows a healthy padlock and the certificate details look fine. Yet calling that endpoint from the backend fails like this.

curl -sS https://api.example.com/v1/health
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.html

The most common conclusion at this point is "curl does not know the latest CA". So people update ca-certificates, and when that does not help they add -k or turn validation off in the application. That is a measure that covers the cause, and it usually pushes a misconfigured server into production.

The real cause is almost always one thing. The server is not sending the intermediate certificate, and the browser succeeded by filling that gap on its own. This post explains that mechanism precisely and organises things so you can name the cause from the error message alone.

Reading the TLS 1.3 handshake as a sequence of messages

To debug, you need to know which messages travel in which order. TLS 1.3 completes in a single round trip.

Client                                                  Server
  |                                                      |
  |-- ClientHello ------------------------------------->  |
  |     supported_versions: TLS 1.3                       |
  |     key_share: x25519 public key                      |
  |     server_name: api.example.com   (SNI, plaintext)   |
  |     application_layer_protocol_negotiation: h2, http/1.1
  |                                                      |
  |  <------------------------------------ ServerHello -- |
  |     key_share: x25519 public key                      |
  |     ---- everything from here on is encrypted ----    |
  |  <------------------------------- EncryptedExtensions |
  |  <----------------------------- CertificateRequest(*) |
  |  <------------------------------------- Certificate   |
  |  <------------------------------- CertificateVerify   |
  |  <------------------------------------------ Finished |
  |                                                      |
  |-- Certificate(*) ---------------------------------->  |
  |-- CertificateVerify(*) ---------------------------->  |
  |-- Finished ---------------------------------------->  |
  |-- Application Data (first HTTP request) ----------->  |
  |                                                      |
  (*) appears only when mTLS is required

Three facts with direct operational impact come out of this.

First, every message after ServerHello is encrypted. In TLS 1.2 you could pull the server certificate straight out of a packet capture, but in TLS 1.3 you cannot. If Wireshark does not show you the certificate, that does not mean the server failed to send one. To inspect the certificate you must do it from the client side, with a tool such as openssl s_client.

Second, the SNI in ClientHello is still plaintext. Even with everything else encrypted, the hostname you are connecting to is exposed. This is part of why Encrypted Client Hello came along. From a debugging perspective it is actually useful: you can always read the SNI out of a packet capture.

Third, the client sends Finished and the first request back to back. The client considers the handshake finished before the server has validated the client certificate. That is why an mTLS authentication failure appears not as a handshake error but as a connection drop immediately after the first request goes out. This difference is the core of what makes mTLS debugging hard.

Without SNI the server hands you whatever certificate it likes

Hundreds of sites sitting on a single IP and port is the standard arrangement these days. From the TCP connection alone the server cannot know which certificate to present. The SNI extension in ClientHello is what tells it.

Connect without SNI and the server returns the certificate of the default virtual host.

# Connect straight to the IP with SNI omitted
openssl s_client -connect 203.0.113.40:443 -noservername </dev/null 2>/dev/null \
  | openssl x509 -noout -subject
subject=CN = default.vhost.example.net
# Specify SNI and the correct certificate comes back
openssl s_client -connect 203.0.113.40:443 -servername api.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject
subject=CN = api.example.com

The most frequent mistake with openssl s_client lives right here. Use only -connect and omit -servername, and no SNI is transmitted, so you end up looking at a different certificate from the real service and drawing the wrong conclusion. Recent OpenSSL sets the hostname from -connect as SNI automatically, but not when you connect by IP or use an older version. Making a habit of always specifying -servername is the safe path.

Clients that do not send SNI really do exist. Very old Java clients, some embedded devices and certain monitoring agents behave this way. If only such clients produce certificate errors, the cause is not the certificate but the missing SNI. What you choose as the default virtual host on the server side becomes the countermeasure.

It is also worth knowing how to check SNI in a capture.

sudo tcpdump -nn -i any -A 'tcp port 443 and tcp[((tcp[12:1] & 0xf0) >> 2)] = 0x16' | head -40

Chain validation and the missing intermediate — where the browser and curl diverge

Validation starts at the leaf certificate and climbs the issuer links, succeeding when it reaches a root held in the local trust store. The responsibility of the server is to send every intermediate certificate except the root.

A healthy chain looks like this.

openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
  | sed -n '/Certificate chain/,/---/p'
Certificate chain
 0 s:CN = api.example.com
   i:C = US, O = Let's Encrypt, CN = R11
 1 s:C = US, O = Let's Encrypt, CN = R11
   i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
---

A server missing the intermediate certificate comes out like this.

Certificate chain
 0 s:CN = api.example.com
   i:C = US, O = Let's Encrypt, CN = R11
---
...
Verify return code: 20 (unable to get local issuer certificate)

There is a 0 but no 1. The client has to find the issuer named R11 in the trust store, but the trust store holds only the root, ISRG Root X1, and not the intermediate R11. So the chain breaks.

Then why does the browser succeed? This is the crux.

Inside the leaf certificate is a URL from which the issuer certificate can be downloaded.

openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -text | grep -A3 'Authority Information Access'
            Authority Information Access:
                OCSP - URI:http://r11.o.lencr.org
                CA Issuers - URI:http://r11.i.lencr.org/

Chrome, Safari and Edge, when they cannot find the issuer during validation, send an HTTP request to that CA Issuers URL and download the intermediate certificate themselves. This is called AIA fetching, or AIA chasing. Firefox does it slightly differently: it preloads known intermediate certificates and caches any intermediate it has seen once.

OpenSSL, by contrast, does not follow AIA by default. curl, wget, Python requests, Go and most of Node fall into this category. Java also does not follow it by default and requires the com.sun.security.enableAIAcaIssuers property to be turned on.

Which gives us the following proposition. If it works in the browser but fails in curl, it is the server that is wrong, not the client. The browser is merely papering over the server mistake. On top of that, AIA fetching is a network request, so every first connection spends an extra tens to hundreds of milliseconds. That turns into user-visible latency too.

Two commands are all it takes to confirm.

# Check whether the chain the server sends validates on its own (system default trust store)
openssl s_client -connect api.example.com:443 -servername api.example.com \
  -verify_return_error </dev/null 2>&1 | tail -3

# Dump the chain to see how many certificates there are
openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null 2>/dev/null \
  | grep -c 'BEGIN CERTIFICATE'
Verify return code: 0 (ok)
2

The fix is server configuration. The nginx ssl_certificate directive must point at a file (fullchain) with the leaf certificate and the intermediate certificate concatenated in order. Apache takes fullchain in SSLCertificateFile in recent versions, while older versions need SSLCertificateChainFile specified separately. On a load balancer, check that you did not leave the chain field empty on the certificate registration screen.

An error message dictionary

Reading the message accurately determines most of the cause on its own.

Error messageActual causeCommand to confirmAction
unable to get local issuer certificateThe server did not send the intermediate, or no root locallyCount the s_client chain, check the trust store existsDeploy fullchain on the server, or install ca-certificates
certificate has expirednotAfter has passed, or the client clock is offopenssl x509 -noout -dates, check dateRenew, or synchronise NTP
certificate is not yet validBefore notBefore, effectively a client clock problemdate, timedatectlSynchronise the clock
hostname mismatch, no alternative subject nameThe name you connected to is not in the certificate SAN listopenssl x509 -noout -ext subjectAltNameAdd the name to SAN, or connect with the right SNI
self signed certificate in certificate chainAn internal CA or an intercepting proxy sits in the middleCheck the issuer at the top of the chainRegister the internal root in the trust store
self signed certificateThe leaf certificate itself is self-signedCheck whether issuer and subject are the sameGet a properly issued certificate
tlsv1 alert protocol versionClient and server minimum protocol versions do not overlapCompare with -tls1_2 and -tls1_3 given to s_client separatelyAdjust server configuration or update the client
sslv3 alert handshake failureNo common cipher suite, or no client certificate under mTLSopenssl ciphers, give -cert and -key to s_clientAlign cipher suites or present a client certificate
certificate required (alert 116)Under TLS 1.3 mTLS the client sent an empty certificateServer logs, give -cert and -key to s_clientConfigure the client certificate

Two misunderstandings need correcting.

On hostname mismatch, modern clients do not look at the CN field at all and check only subjectAltName. Concluding that things are fine because the domain is in CN has been wrong since 2017. Chrome removed the CN fallback in version 58, and OpenSSL and Go did the same. You must check SAN.

openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -ext subjectAltName
X509v3 Subject Alternative Name:
    DNS:api.example.com, DNS:api-internal.example.com

certificate has expired is frequently misdiagnosed too. Quite often it is not the server certificate but the client clock that is wrong. Embedded devices without a clock, VMs booted after a long shutdown, and containers whose time drifted from the host are the classic cases. Making a habit of checking the expiry date and the current time together saves time.

openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -dates
date -u
notBefore=Jun  2 08:14:31 2026 GMT
notAfter=Aug 31 08:14:30 2026 GMT
Sun Jul 26 05:11:20 UTC 2026

The order to check things with openssl s_client

It helps to memorise one command that shows everything at once.

HOST=api.example.com
PORT=443

openssl s_client -connect "${HOST}:${PORT}" -servername "${HOST}" \
  -showcerts -status -alpn h2,http/1.1 </dev/null 2>&1 | head -60

Read the key output like this.

CONNECTED(00000003)
depth=2 C = US, O = Internet Security Research Group, CN = ISRG Root X1
verify return:1
depth=1 C = US, O = Let's Encrypt, CN = R11
verify return:1
depth=0 CN = api.example.com
verify return:1
---
Certificate chain
 0 s:CN = api.example.com
   i:C = US, O = Let's Encrypt, CN = R11
 1 s:C = US, O = Let's Encrypt, CN = R11
   i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
---
ALPN protocol: h2
OCSP response: no response sent
---
New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
Verify return code: 0 (ok)

Verify return code: 0 (ok) is the final verdict. The verify lines that come down from the highest depth tell you at which step the chain broke.

Splitting the test by protocol is useful too.

openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_3 </dev/null 2>&1 | grep -E 'New,|Cipher'
openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_2 </dev/null 2>&1 | grep -E 'New,|Cipher'
New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
New, TLSv1.2, Cipher is ECDHE-RSA-AES128-GCM-SHA256

Protocols that use STARTTLS, such as mail or databases, need an option added.

openssl s_client -connect smtp.example.com:587 -starttls smtp -servername smtp.example.com </dev/null
openssl s_client -connect db.example.com:5432 -starttls postgres </dev/null

When looking with curl, the following combination carries the most information.

curl -v --resolve api.example.com:443:203.0.113.40 https://api.example.com/v1/health 2>&1 | grep -E '^\*'
* Server certificate:
*  subject: CN=api.example.com
*  start date: Jun  2 08:14:31 2026 GMT
*  expire date: Aug 31 08:14:30 2026 GMT
*  subjectAltName: host "api.example.com" matched cert's "api.example.com"
*  issuer: C=US; O=Let's Encrypt; CN=R11
*  SSL certificate verify ok.

--resolve bypasses DNS and connects to a specific IP while keeping SNI and the Host header on the original name. It is essential when verifying individual nodes behind a load balancer one by one.

Trust stores, containers, and where mTLS fails

The other axis on which validation fails is the client-side trust store. Paths and refresh commands differ per distribution.

# Debian, Ubuntu
ls -l /etc/ssl/certs/ca-certificates.crt
cp corp-root.crt /usr/local/share/ca-certificates/corp-root.crt
update-ca-certificates

# RHEL, Rocky, Fedora
ls -l /etc/pki/tls/certs/ca-bundle.crt
cp corp-root.crt /etc/pki/ca-trust/source/anchors/
update-ca-trust extract

# Alpine
apk add --no-cache ca-certificates
cp corp-root.crt /usr/local/share/ca-certificates/
update-ca-certificates

The most frequent container accident is a minimal image with no trust store at all. Put a Go binary on top of scratch or alpine and it fails like this.

x509: certificate signed by unknown authority

Nothing is wrong with the certificate; there is simply not a single root inside the image to compare against. On Alpine, install the ca-certificates package; for a static binary, use a distroless base image or copy the certificate bundle in from a multi-stage build.

FROM alpine:3.20 AS certs
RUN apk add --no-cache ca-certificates

FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY app /app
ENTRYPOINT ["/app"]

It is also worth remembering that the environment variable pointing at the trust store differs per runtime.

export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt   # OpenSSL, Go, curl
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt  # Python requests
export NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/corp-root.crt  # Node
# Java uses a separate keystore
keytool -importcert -alias corp-root -file corp-root.crt \
  -keystore "$JAVA_HOME/lib/security/cacerts" -storepass changeit -noprompt

If self signed certificate in certificate chain only appears on the corporate network, the cause is usually an intercepting proxy. When a company runs TLS inspection appliances, every certificate comes out re-signed by an internal CA. Looking at the issuer at the top of the chain reveals it immediately.

mTLS doubles the number of failure points. The order to check is as follows.

# Check whether the server requests a client certificate, and which CAs it accepts
openssl s_client -connect mtls.example.com:8443 -servername mtls.example.com </dev/null 2>&1 \
  | sed -n '/Acceptable client certificate CA names/,/^---/p'
Acceptable client certificate CA names
C = KR, O = Example Internal CA, CN = Example Issuing CA
Client Certificate Types: RSA sign, ECDSA sign

This list is decisive. If the issuer of your client certificate is not in it, that certificate will never get through. Check the issuer.

openssl x509 -in client.crt -noout -issuer -subject -dates
openssl verify -CAfile ca.crt client.crt
issuer=C = KR, O = Example Internal CA, CN = Example Issuing CA
subject=CN = payments-service
notBefore=Jul  1 00:00:00 2026 GMT
notAfter=Jul  1 00:00:00 2027 GMT
client.crt: OK

Now present the client certificate and connect.

openssl s_client -connect mtls.example.com:8443 -servername mtls.example.com \
  -cert client.crt -key client.key -CAfile ca.crt </dev/null 2>&1 | tail -5

curl -sS --cert client.crt --key client.key --cacert ca.crt \
  https://mtls.example.com:8443/v1/health

The TLS 1.3 property mentioned earlier shows itself here. Even when the client certificate is rejected, the handshake looks like it succeeded, and the error appears only after the first request has been sent.

curl: (56) OpenSSL SSL_read: error:0A000412:SSL routines::sslv3 alert bad certificate, errno 0

Looking at client logs alone makes it look like the connection dropped mid-request, so an mTLS failure must always be examined alongside server logs. Whether the extended key usage is right is another frequently missed item. A server certificate needs serverAuth and a client certificate needs clientAuth.

openssl x509 -in client.crt -noout -ext extendedKeyUsage
X509v3 Extended Key Usage:
    TLS Web Client Authentication

Closing — count the chain before you fix the client

The order of judgement for certificate problems summarises briefly.

First, connect with openssl s_client and always add -servername, then count how many entries appear under Certificate chain in the output. If only the leaf certificate shows up, the cause is settled at that moment. The server is not sending the intermediate certificate, and the browser was filling that gap through AIA. Instead of touching the client, deploy fullchain on the server and you are done.

If it fails even with a complete chain, that is when it is time to look at the trust store. Check whether ca-certificates exists in the container, whether the internal CA is registered, and whether the per-runtime environment variables are right.

And the option of turning validation off should be kept until the very last. -k and verify=False do not solve the problem; they only make it invisible, and shipping in that state leaves you fully exposed to interception attacks. Most certificate errors are resolved precisely by one line of server configuration.