UNABLE_TO_VERIFY_LEAF_SIGNATURE, 자체 서명 인증서, NODE_TLS | HTTPS만 실패하면?
UNABLE_TO_VERIFY_LEAF_SIGNATURE는 코드가 틀린 게 아니라 노드가 HTTPS 리프 서명을 신뢰 저장소로 검증하지 못해 핸드셰이크를 끊은 줄입니다. 중간서, 회사 프록시, mkcert, NODE_EXTRA_CA_CERTS. Node.js, Next.js, 한국 1인 개발자 기준. 2026년 9월 Node.js TLS·CLI 문서.
상대가 준 맨 앞 인증서 서명을 노드가 믿지 못해서 납니다. 브라우저는 운영체제 저장소를 쓰고, 노드는 자신이 묶은 루트 목록을 씁니다. 브라우저로 같은 주소가 초록인데 fetch만 실패하는 이유가 여기 있습니다.
리프는 서버 이름 인증서입니다. 그 아래 중간 인증서가 빠지면 루트까지 사다리가 끊깁니다. 회사 SSL 검사 프록시는 자체 루트를 끼워 넣는데, 크롬에는 그 루트가 있고 노드에는 없습니다. 한국 사무실망에서 카카오·토스 엔드포인트만 실패하는 그림이 자주 나옵니다.
자체 서명 로컬 HTTPS도 같습니다. mkcert로 만든 인증서를 브라우저에는 넣었는데 노드 프로세스에는 안 넣으면, next dev --experimental-https 앞의 API 호출이 이 줄로 죽습니다. 포트가 거절된 줄은 ECONNREFUSED, 붙었다가 잘린 줄은 ECONNRESET입니다.
0으로 끄지 말 것 | NODE_TLS_REJECT_UNAUTHORIZED=0은 검증을 전부 끕니다. 로컬 한 줄 우회가 프리뷰까지 따라가면 중간자 검사를 못 합니다. 원인 체인을 고치는 쪽이 안전합니다.
브라우저는 되고 노드 fetch만 실패하면 루트 저장소가 다른 것이다
먼저 가릴 세 칸
빠진 중간서, 회사 프록시, 로컬 자체 서명 세 칸이면 원인이 갈립니다.
칸
증상
먼저 할 일
중간 인증서 누락
일부 API만 실패, openssl은 경고
서버가 체인 전체를 보내는지 확인
회사 SSL 검사
사무실망만 실패, 테더링은 통과
회사 루트를 NODE_EXTRA_CA_CERTS로
로컬 자체 서명
localhost HTTPS만 실패
mkcert 루트를 노드에 연결
만료·이름 불일치
다른 코드(CERT_HAS_EXPIRED 등)
이 글이 아니라 그 코드 문서
환경 변수 우회
로컬만 통과, CI는 실패
0 플래그가 로컬 셸에만 있는지
네 번째 칸은 코드가 다릅니다. 만료는 CERT_HAS_EXPIRED, 이름 불일치는 ERR_TLS_CERT_ALTNAME_INVALID입니다. 리프 서명 실패만 이 글에서 봅니다. 호스트 이름을 못 푸는 줄은 ENOTFOUND입니다.
상대가 보내는 체인 확인
openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null
# 출력에서 인증서가 1장만이면 중간서가 빠진 경우가 많다
# Verify return code 가 0이 아니면 원인 숫자가 함께 찍힌다
추가 인증서는 끄기가 아니다
회사 루트나 mkcert 루트는 노드에 파일로 넘깁니다. NODE_EXTRA_CA_CERTS는 기본 저장소에 파일을 더할 뿐, 검증을 끄지 않습니다. 공식 CLI 문서도 이 변수를 추가 신뢰 목록으로 설명합니다.
PEM 여러 장을 한 파일에 이어 붙여도 됩니다. 맥 키체인에만 넣고 이 변수를 비우면 브라우저는 되고 크론·깃허브 액션은 실패합니다. 한국에서 launchd로 발행 스크립트를 돌리는 1인이라면, 셸 프로필에만 변수가 있고 plist에는 없는 사고를 자주 봅니다.
fetch에 ca 옵션을 직접 주는 길은 언디치 에이전트를 쓸 때입니다. 기본 fetch는 환경 변수가 더 단순합니다. 환경 변수 칸이 빌드와 런타임에서 갈리는 문제는 NEXT_PUBLIC 글과 자리가 다릅니다. 여기는 TLS 검증용 파일 경로만 봅니다.
추가 CA만 넣고 검증은 유지
# 회사 루트 또는 mkcert 루트 PEM
export NODE_EXTRA_CA_CERTS="$HOME/.local/share/mkcert/rootCA.pem"
node -e "fetch('https://localhost:3000/api/health').then(r=>r.text()).then(console.log)"
# 금지에 가까운 우회 (저장소·CI에 넣지 말 것)
# export NODE_TLS_REJECT_UNAUTHORIZED=0
[ ] openssl로 체인이 몇 장인지 봤다
[ ] 사무실망과 테더링을 비교했다
[ ] NODE_EXTRA_CA_CERTS에 PEM 경로를 넣었다
[ ] 0 플래그를 저장소와 CI에서 뺐다
[ ] 크론·액션 환경에도 같은 변수가 있는지 봤다
추가 인증서 변수는 검증을 끄지 않고 루트만 더한다
로컬 HTTPS와 프리뷰가 갈릴 때
로컬만 실패하면 자체 서명이 원인인 경우가 많습니다. 프리뷰만 실패하면 서버가 중간서를 안 붙였거나, 버셀 앞 인증서와 오리진 인증서가 다릅니다. 함수가 오리진을 HTTPS로 다시 부를 때 노드 검증이 한 번 더 돕니다.
카카오 로그인 리다이렉트가 HTTPS인데 로컬 콜백이 HTTP면, 브라우저 혼합 콘텐츠와 TLS 검증이 겹칩니다. 콜백 URL은 콘솔에 등록한 값과 한 글자도 같아야 하고, 로컬에서 HTTPS를 쓰려면 mkcert 루트를 노드에도 넣어야 합니다.
시간 초과로 보이는 경우도 있습니다. 핸드셰이크가 오래 실패하면 겉에 타임아웃이 남고 안쪽에 검증 실패가 있습니다. ETIMEDOUT만 보고 한도를 늘리면 인증서 문제가 숨습니다. cause 코드를 같이 찍으세요.