- Published on
Nginx mTLS 설정 후 495 SSL 오류 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Nginx에 mTLS를 적용하면 서버 인증서만으로는 통과하던 요청이 갑자기 막히고, 접근 로그에 495가 찍히는 경우가 많습니다. 이 코드는 HTTP 표준 상태 코드는 아니지만, Nginx가 TLS 핸드셰이크 단계에서 클라이언트 인증서 검증에 실패했을 때 자주 노출되는 대표 증상입니다.
문제는 495 자체가 친절한 에러 메시지를 주지 않는다는 점입니다. 원인은 대체로 인증서 체인, 신뢰 저장소, 검증 깊이, SNI, 프록시 계층 전달(특히 Ingress나 ALB 앞단), 그리고 클라이언트 인증서의 용도 확장 값(EKU) 같은 “TLS 주변 설정”에 숨어 있습니다.
이 글에서는 Nginx mTLS 구성의 핵심 포인트를 짚고, 495를 빠르게 재현·진단·수정하는 체크리스트와 설정 예시를 제공합니다.
495 SSL 오류가 의미하는 것
Nginx가 495를 반환하는 전형적인 상황은 다음 중 하나입니다.
- 클라이언트가 인증서를 아예 보내지 않음
- 클라이언트 인증서가 만료되었거나 아직 유효하지 않음
- 서버가 신뢰하는 CA 체인에 클라이언트 인증서의 발급 CA가 없음
- 클라이언트 인증서 체인이 중간 인증서 누락으로 불완전함
ssl_verify_depth가 부족해 체인 검증이 실패함- 클라이언트 인증서의 EKU가
clientAuth가 아님 - 프록시 계층에서 TLS가 종료되어 Nginx까지 클라이언트 인증서가 전달되지 않음
핵심은 495가 “애플리케이션 레벨”이 아니라 “TLS 레벨”에서 실패했다는 신호라는 점입니다. 따라서 앱 로그보다 Nginx 에러 로그와 TLS 핸드셰이크 디버깅이 우선입니다.
가장 먼저 확인할 로그 설정
기본 설정만으로는 원인 파악이 어렵습니다. mTLS를 켰다면 최소한 아래처럼 에러 로그 레벨과 접근 로그 포맷에 클라이언트 인증서 관련 변수를 포함해 두는 것을 권장합니다.
error_log /var/log/nginx/error.log info;
log_format mtls '$remote_addr - $host "$request" $status '
'ssl_verify=$ssl_client_verify '
's_dn="$ssl_client_s_dn" '
'i_dn="$ssl_client_i_dn" '
'serial=$ssl_client_serial '
'fp=$ssl_client_fingerprint';
access_log /var/log/nginx/access.log mtls;
$ssl_client_verify값이SUCCESS가 아니면 거의 항상 인증서 검증 문제입니다.$ssl_client_s_dn가 비어 있으면 클라이언트 인증서를 아예 받지 못했을 가능성이 큽니다.
정석 mTLS Nginx 설정 예시
서버 인증서와 별개로 “클라이언트 인증서 검증용 CA”를 지정해야 합니다. 여기서 가장 많이 틀리는 부분이 ssl_client_certificate에 무엇을 넣어야 하는지입니다.
- 넣어야 하는 것: 클라이언트 인증서를 발급한 CA 체인(루트 또는 루트+중간)
- 넣으면 안 되는 것: 서버 인증서 체인, 혹은 클라이언트 인증서 자체
server {
listen 443 ssl http2;
server_name api.example.com;
# 서버 인증서 체인
ssl_certificate /etc/nginx/tls/server.fullchain.pem;
ssl_certificate_key /etc/nginx/tls/server.key;
# mTLS: 클라이언트 인증서 검증용 CA
ssl_client_certificate /etc/nginx/mtls/client-ca-chain.pem;
ssl_verify_client on;
ssl_verify_depth 2;
location / {
proxy_pass http://upstream;
}
}
ssl_verify_depth는 체인의 길이에 따라 조정합니다. 예를 들어 클라이언트 인증서 -> 중간 CA -> 루트 CA 구조라면 깊이는 보통 2 이상이 필요합니다.
원인 1: 클라이언트가 인증서를 보내지 않음
클라이언트가 인증서를 보내지 않으면 Nginx는 검증을 할 수 없고, ssl_verify_client on;일 때 요청을 차단합니다.
재현 및 확인
curl로 인증서 없이 호출해보면 바로 확인됩니다.
curl -vk https://api.example.com/health
이때 접근 로그에서 $ssl_client_verify가 NONE 또는 비어 있고, $ssl_client_s_dn가 비어 있으면 “클라이언트 인증서 미제공” 가능성이 높습니다.
해결
- 클라이언트에
--cert와--key를 제공 - 혹은 “일부 경로만 mTLS 강제”가 목적이라면
ssl_verify_client optional;로 받고, 특정 location에서만 강제 로직을 적용
예: /internal만 mTLS 강제
ssl_verify_client optional;
location /internal/ {
if ($ssl_client_verify != SUCCESS) { return 401; }
proxy_pass http://upstream;
}
주의: if 사용은 신중해야 하지만, 위처럼 단순 반환에는 실무에서 자주 사용됩니다. 더 안전하게는 map과 satisfy 조합으로 구현할 수도 있습니다.
원인 2: ssl_client_certificate에 잘못된 파일을 넣음
mTLS에서 가장 흔한 실수는 ssl_client_certificate에 서버 인증서 체인(fullchain)을 넣는 것입니다. 이러면 서버는 “서버 인증서를 발급한 CA”만 신뢰하게 되고, 클라이언트 인증서는 검증에 실패합니다.
올바른 파일 구성
server.fullchain.pem: 서버 인증서 + 서버 중간 인증서client-ca-chain.pem: 클라이언트 인증서를 발급한 CA 체인(루트 또는 루트+중간)
OpenSSL로 CA 체인 내용을 확인합니다.
openssl x509 -in /etc/nginx/mtls/client-ca-chain.pem -noout -subject -issuer
여러 장이 합쳐진 번들이라면 각 인증서의 subject와 issuer가 기대한 CA 계층인지 확인하세요.
원인 3: 클라이언트 인증서 체인 누락(중간 인증서 미포함)
클라이언트가 자신의 인증서만 보내고, 중간 인증서를 보내지 않으면 서버가 체인을 완성하지 못해 검증이 실패할 수 있습니다.
확인 방법
클라이언트 인증서 파일이 단일 인증서인지, 체인 번들인지 확인합니다.
# 인증서가 여러 개인지 대략 확인
awk 'BEGIN{c=0} /BEGIN CERTIFICATE/{c++} END{print c}' client.pem
또는 서버 측에서 핸드셰이크를 강제로 수행해봅니다.
openssl s_client -connect api.example.com:443 -servername api.example.com \
-cert client.pem -key client.key -showcerts
출력 중 Verify return code가 0 (ok)가 아니면 원인을 따라가야 합니다.
해결
- 클라이언트 측에서
client.pem에클라이언트 인증서 + 중간 인증서를 함께 제공(툴/라이브러리마다 다름) - 서버 측에서
ssl_client_certificate에 중간 CA를 포함한 체인을 제공
실무적으로는 서버 측 client-ca-chain.pem에 중간 CA까지 포함해두는 편이 운영 안정성이 좋습니다.
원인 4: EKU가 clientAuth가 아님
클라이언트 인증서가 “서버 인증서 용도”로 발급되었거나 EKU가 비어 있으면 검증이 실패하거나, 보안 정책에 따라 거부될 수 있습니다.
확인:
openssl x509 -in client.pem -noout -text | grep -n "Extended Key Usage" -A1
기대 값은 보통 TLS Web Client Authentication입니다.
해결은 재발급이 정답입니다. 사설 PKI를 운영한다면 발급 템플릿에서 clientAuth를 포함시키세요.
원인 5: ssl_verify_depth 부족
클라이언트 인증서 체인이 길거나, 루트+중간이 여러 단계인 조직 PKI에서는 깊이가 부족해 실패합니다.
해결:
ssl_verify_depth 3;
깊이를 무작정 크게 하는 것보다, 실제 체인 길이를 확인한 뒤 필요한 만큼만 올리는 것을 권장합니다.
원인 6: 프록시 앞단에서 TLS가 종료되어 인증서가 사라짐
Kubernetes Ingress, ALB, CDN, WAF 등 앞단에서 TLS를 종료하면 Nginx는 “클라이언트 인증서”를 받을 수 없습니다. 이 경우 Nginx에서 mTLS를 설정해도 항상 실패하거나, 아예 mTLS가 동작하지 않습니다.
패턴별 정리
- mTLS를 Nginx에서 하고 싶다: 앞단은 TCP 패스스루가 필요
- 앞단에서 mTLS를 끝내고 싶다: Nginx는 헤더 기반으로 인증 결과를 신뢰할지 설계 필요(권장 난이도 높음)
Kubernetes 환경에서 통신 계층 문제는 증상이 비슷하게 나타나는 경우가 많습니다. 네트워크 단절이나 MTU 문제로 TLS가 깨지는 케이스도 있으니, mTLS 설정을 바꿨는데 “간헐적 실패”가 섞여 있다면 함께 점검해보는 것이 좋습니다.
원인 7: SNI 불일치로 다른 server 블록을 탐
server_name과 SNI가 어긋나면, 의도하지 않은 server 블록이 선택되고 그 블록의 mTLS 설정 때문에 실패하는 경우가 있습니다.
확인:
openssl s_client -connect api.example.com:443 -servername api.example.com
-servername을 빼면 기본 서버로 붙습니다.- 특정 클라이언트(레거시 SDK, 일부 프록시)가 SNI를 보내지 않는다면 “기본 서버”에 mTLS 정책을 어떻게 둘지 결정해야 합니다.
실전 디버깅 루틴(10분 컷)
아래 순서대로 보면 대부분의 495는 빠르게 좁혀집니다.
1) Nginx에서 실제로 인증서를 받는지 확인
접근 로그에서 다음을 봅니다.
$ssl_client_s_dn가 비어 있나$ssl_client_verify가SUCCESS인가
비어 있으면 “클라이언트 미제공” 또는 “앞단 TLS 종료” 가능성이 큽니다.
2) 서버가 신뢰하는 CA 체인 확인
ssl_client_certificate 파일이 “클라이언트 발급 CA”인지 확인합니다.
openssl x509 -in /etc/nginx/mtls/client-ca-chain.pem -noout -subject -issuer
3) 클라이언트 인증서 체인과 EKU 확인
openssl x509 -in client.pem -noout -dates
openssl x509 -in client.pem -noout -text | grep -n "Extended Key Usage" -A1
4) OpenSSL로 핸드셰이크 재현
openssl s_client -connect api.example.com:443 -servername api.example.com \
-cert client.pem -key client.key -state -verify_return_error
여기서 나오는 verify 에러 메시지가 495의 실질 원인에 가장 가깝습니다.
운영 팁: 실패 원인을 더 명확히 노출하기
mTLS 실패는 TLS 단계에서 차단되므로, 사용자에게 친절한 JSON 에러를 내려주기 어렵습니다. 그 대신 관측 가능성을 높이는 방향이 좋습니다.
- 에러 로그 레벨을 일시적으로
info또는 필요 시debug로 올리기(성능/보안 고려) - 접근 로그에
$ssl_client_verify와 DN, fingerprint 기록 - upstream으로 프록시할 때 클라이언트 인증서 정보를 헤더로 전달(내부망에서만)
예:
proxy_set_header X-Client-Verify $ssl_client_verify;
proxy_set_header X-Client-DN $ssl_client_s_dn;
proxy_set_header X-Client-Fingerprint $ssl_client_fingerprint;
주의: 이 헤더들은 신뢰 경계 밖으로 노출되면 보안 문제가 됩니다. 반드시 내부 통신에서만 사용하고, 외부 요청이 임의로 주입하지 못하도록 앞단에서 제거하거나 Nginx에서 덮어쓰는 방식으로 고정하세요.
자주 묻는 질문
ssl_verify_client optional이면 495가 사라지나
대부분의 경우 사라집니다. 다만 optional은 “인증서가 있으면 검증하되, 없어도 통과”이므로 보안 요구사항을 충족하는지 먼저 확인해야 합니다. 특정 경로만 강제하는 방식으로 절충하는 경우가 많습니다.
495가 아니라 400이나 496이 찍히는 경우도 있나
환경과 로그 설정에 따라 다르게 보일 수 있습니다. 중요한 건 상태 코드 숫자보다도, $ssl_client_verify와 OpenSSL 핸드셰이크 결과로 원인을 확정하는 것입니다.
마무리
Nginx mTLS 적용 후 495가 발생하면, 대부분은 “클라이언트 인증서를 못 받았거나, 신뢰할 CA 체인이 맞지 않거나, 체인 검증 파라미터가 부족한” 문제로 귀결됩니다.
ssl_client_certificate는 클라이언트 발급 CA 체인- 클라이언트는 인증서와 키를 올바르게 제공하고, 필요 시 중간 인증서를 포함
ssl_verify_depth, EKU, SNI, 프록시 TLS 종료 여부를 순서대로 점검
Kubernetes나 멀티 프록시 환경에서는 네트워크 이슈가 TLS 실패로 위장되기도 하니, 간헐적 실패가 섞이면 MTU나 경로 문제까지 함께 확인하는 것을 권장합니다.