Nginx mTLS 설정: 클라이언트 인증서로 관리자 페이지 보호하기
OpenSSL로 사설 CA를 구축하고 Nginx에서 mTLS를 설정하여 클라이언트 인증서 검증을 구현하는 방법을 설명합니다. 공인 인증서와 별개로 운영되는 클라이언트 인증서 체인 구성법과 400 Bad Request 오류를 방지하는 핵심 설정 지시어를 상세히 다룹니다.
mTLS의 역할
mTLS(Mutual TLS)는 Nginx가 모든 클라이언트에게 인증서를 요구하도록 설정하며, 인증서가 없거나 관리자가 신뢰하는 인증 기관(CA)에서 발급되지 않은 경우 요청을 거부합니다. 이 검증은 TLS(Transport Layer Security) 핸드셰이크 과정에서 수행되므로, 유효한 클라이언트 인증서가 없는 호출자는 애플리케이션에 도달할 수 없습니다. 이것이 mTLS의 핵심 장점입니다. 관리자 페이지나 메트릭 엔드포인트를 로그인 페이지 없이 공개 인터넷에 노출해도 봇이 접근할 수 없습니다.
구축 과정은 간단합니다. openssl로 생성한 사설 CA 하나, 사용자별 인증서 하나, 그리고 Nginx 서버 블록에 추가할 세 가지 지시어면 충분합니다. 이 시스템을 장기간 안정적으로 운영하기 위해서는 운영 관리가 중요합니다. 따라서 이 가이드의 대부분은 인증서 수명, 폐기, 사용자별 인증서 관리, 그리고 클라이언트 요청이 거부되었을 때 원인을 파악하는 방법에 대해 다룹니다.
두 개의 체인, 하나가 아님
mTLS 설정에는 두 개의 인증서 체인이 존재하며, 이 둘은 서로 아무런 관련이 없습니다. 이 둘을 하나로 합치는 것이 거의 모든 사람이 저지르는 첫 번째 실수입니다.
첫 번째 체인은 서버의 체인입니다. 귀하의 VPS는 Let's Encrypt와 같은 공인 CA에서 발급한 admin.example.com용 인증서를 제시하며, 브라우저는 운영체제와 함께 제공되는 루트 저장소를 통해 이를 검증합니다. mTLS는 이 절반의 과정에 아무런 영향을 주지 않습니다. 만약 오늘 certbot으로 해당 인증서를 발급받았다면, 그대로 유지하십시오. certbot을 사용하여 nginx용 Let's Encrypt 인증서 발급하기를 참조하십시오.
두 번째 체인은 클라이언트의 체인입니다. 귀하는 직접 소규모 CA를 생성하고, 접속이 필요한 각 사용자에게 인증서를 하나씩 서명하여 발급합니다. 그리고 nginx가 클라이언트를 검증할 때 오직 해당 CA만 신뢰하도록 설정합니다. 공인 루트 저장소는 귀하의 CA를 알 필요가 없으며, 알 수도 없습니다. 오직 nginx만이 ssl_client_certificate 파일을 통해 해당 CA를 신뢰하면 됩니다.
따라서 ssl_client_certificate은 nginx가 제시하는 인증서에 전혀 영향을 주지 않으며, Let's Encrypt 체인 또한 어떤 클라이언트의 접속을 허용할지에 영향을 주지 않습니다. ssl_client_certificate을 fullchain.pem로 지정하는 것은 겉보기와 다르게 동작합니다. 해당 지시어는 클라이언트 인증서가 발급될 수 있는 발행자를 지정하는 것이며, 이는 연결의 반대편에 해당합니다. 서버 자체가 아웃바운드 작업 시 귀하의 CA를 신뢰하도록 만드는 것은 별개의 작업이며, Ubuntu 신뢰 저장소에 자체 CA 추가하기에서 다룹니다. 시스템 신뢰 저장소는 nginx가 클라이언트를 검증할 때 참조하는 대상이 아닙니다.
OpenSSL로 직접 클라이언트 CA 구축하기
CA는 웹 서버가 아닌 별도의 장소에 구축하십시오. Nginx에는 CA의 공개 인증서만 있으면 됩니다. CA 개인 키는 새로운 클라이언트 인증서를 서명하는 데 사용되므로, 인터넷에 노출된 서버에 이 키를 두면 침해 사고 발생 시 공격자가 원하는 대로 유효한 클라이언트 인증서를 생성할 수 있게 됩니다.
mkdir -p ~/client-ca/certs ~/client-ca/newcerts ~/client-ca/private ~/client-ca/csr
cd ~/client-ca
chmod 700 private
touch index.txt
echo 1000 > serial
echo 1000 > crlnumberindex.txt, serial, crlnumber는 CA 데이터베이스입니다. openssl ca은 이 파일들이 없으면 실행되지 않습니다. 또한 이 파일들은 추후 인증서 폐기를 가능하게 합니다. 폐기 목록(CRL)에는 일련번호가 명시되므로, CA는 어떤 일련번호가 누구에게 발급되었는지 기록하고 있어야 합니다.
~/client-ca/openssl.cnf를 작성하십시오. openssl ca은 ~을 확장하지 않으므로, dir를 해당 디렉터리의 실제 경로로 설정하십시오.
[ ca ]
default_ca = client_ca
[ client_ca ]
dir = /home/you/client-ca
database = $dir/index.txt
new_certs_dir = $dir/newcerts
certificate = $dir/ca.crt
private_key = $dir/private/ca.key
serial = $dir/serial
crlnumber = $dir/crlnumber
default_md = sha256
default_days = 365
default_crl_days = 30
policy = policy_loose
rand_serial = no
unique_subject = no
email_in_dn = no
[ policy_loose ]
commonName = supplied
countryName = optional
stateOrProvinceName = optional
organizationName = optional
organizationalUnitName = optional
emailAddress = optional
[ client_ext ]
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer이제 CA 키와 자체 서명된 인증서를 생성합니다.
openssl genrsa -aes256 -out private/ca.key 4096
chmod 600 private/ca.key
openssl req -x509 -new -key private/ca.key -sha256 -days 3650 \
-subj "/O=Example Ops/CN=Example Ops Client CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-out ca.crt-aes256은 CA 키에 암호를 설정하므로, 서명을 실행할 때마다 암호를 입력해야 합니다. 이것이 바로 암호를 사용하는 목적입니다. 생성된 내용을 확인하십시오.
openssl x509 -in ca.crt -noout -subject -dates -ext basicConstraintsSubject는 CA 정보여야 하며 유효 기간은 10년으로 설정합니다. 확장 필드 라인에는 CA:TRUE, pathlen:0가 포함되어야 합니다. pathlen:0은 이 CA가 엔드 인증서에 서명할 수 있으며 다른 CA에 서명할 수는 없음을 의미합니다. 이를 통해 인증서 체인을 정확히 1단계 깊이로 유지할 수 있으며 ssl_verify_depth을 그대로 둘 수 있습니다.
사용자별 클라이언트 인증서 발급
사용자당 인증서를 하나씩 발급하십시오. 팀 전체가 하나의 인증서를 공유해서는 안 됩니다. 공유 인증서는 특정 인원만 접근을 차단할 수 없으며, 누가 호출했는지 식별할 수 없기 때문입니다.
openssl genrsa -out private/alice.key 2048
openssl req -new -key private/alice.key -out csr/alice.csr \
-subj "/O=Example Ops/CN=alice"
openssl ca -config openssl.cnf -extensions client_ext \
-days 365 -notext -in csr/alice.csr -out certs/alice.crtopenssl ca는 서명할 인증서 내용을 출력한 뒤 CA 암호를 묻고, 확인을 위해 두 번 입력을 요청한 다음 index.txt에 한 줄을 추가합니다. 스크립트로 작성할 때는 -batch를 추가하십시오. client_ext 섹션이 중요한 이유는 그 안의 한 줄, 즉 extendedKeyUsage = clientAuth 때문입니다. serverAuth만 나열된 확장 키 용도(extended key usage)를 가진 인증서는 클라이언트 인증에 부적합하다고 판단되어 거부되므로, 막연한 기대를 하기보다 용도를 명시해야 합니다.
인증서를 전달하기 전에 CA를 통해 쌍이 일치하는지 확인하십시오.
openssl verify -CAfile ca.crt certs/alice.crt이 명령은 certs/alice.crt: OK을 출력합니다. 다른 결과가 나온다면 인증서와 CA가 일치하지 않는 것이며, 어떤 Nginx 설정으로도 해결할 수 없습니다.
브라우저에서 가져올 수 있도록 키와 인증서를 하나의 파일로 묶으십시오.
openssl pkcs12 -export -inkey private/alice.key -in certs/alice.crt \
-name "alice at example ops" -out alice.p12내보내기 과정에서 암호를 묻는데, 이는 전송 중 파일을 보호하기 위함입니다. 파일과 암호는 서로 다른 경로로 전달하고, 원본 .key 대신 .p12를 사용자에게 제공하십시오. -certfile ca.crt을 추가하여 번들에 CA를 포함할 수 있지만, Nginx에는 필요하지 않습니다. Nginx는 이미 ca.crt를 보유하고 있으므로 해당 CA가 직접 서명한 인증서는 자체적으로 검증됩니다.
Ubuntu 24.04에 포함된 OpenSSL 3은 최신 암호화 방식을 사용하여 PKCS#12 파일을 작성하며, 2026년 8월 기준으로 사용 중인 브라우저와 운영체제는 이를 읽을 수 있습니다. 만약 구형 가져오기 도구가 파일을 거부한다면, 해당 도구가 요구하는 이전 알고리즘을 사용하는 -legacy을 추가하여 다시 내보내십시오. 해당 플래그를 사용하기 전에 가져오기 도구가 출력하는 메시지를 먼저 확인하십시오.
ssl_client_certificate 및 ssl_verify_client를 사용한 nginx 설정
CA 인증서만을 서버로 복사합니다.
scp ca.crt user@admin.example.com:/tmp/client-ca.crt
ssh user@admin.example.com \
'sudo install -o root -g root -m 644 /tmp/client-ca.crt /etc/nginx/client-ca.crt'이 경우 644 모드가 적절합니다. CA 인증서는 공개 정보이기 때문입니다. CA 키는 워크스테이션에 보관하십시오.
그다음, 이미 TLS를 종료하고 있는 server 블록에 다음 세 가지 지시어를 추가합니다.
server {
listen 443 ssl;
server_name admin.example.com;
ssl_certificate /etc/letsencrypt/live/admin.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/admin.example.com/privkey.pem;
ssl_client_certificate /etc/nginx/client-ca.crt;
ssl_verify_client on;
ssl_verify_depth 1;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}ssl_verify_depth 1는 nginx의 기본값이며, 클라이언트 인증서가 해당 파일의 CA에 의해 직접 서명되어야 함을 의미합니다. 중간 인증서(intermediate)를 추가하는 경우에만 이 값을 높이십시오. 또한 nginx는 핸드셰이크 과정에서 ssl_client_certificate에 있는 주체 이름을 클라이언트로 전송하며, 브라우저는 이를 통해 어떤 인증서를 제시할지 결정합니다. 이러한 동작 방식 때문에 ssl_trusted_certificate 대신 ssl_client_certificate을 사용해야 합니다. ssl_trusted_certificate도 동일한 방식으로 검증을 수행하지만, 목록을 전송하지는 않습니다.
Ubuntu 24.04는 nginx 1.24를 제공하며, 이 버전에서는 listen 라인에 listen 443 ssl http2;을 사용하여 HTTP/2를 설정합니다. nginx 1.25.1 이상에서는 해당 방식이 더 이상 사용되지 않으며(deprecated), HTTP/2는 별도의 지시어인 http2 on;를 사용합니다. 어떤 방식을 선택하든 인증서 검증에는 영향을 주지 않습니다.
설정을 다시 불러오고 결과를 확인합니다.
sudo nginx -t && sudo systemctl reload nginx
curl -i https://admin.example.com/nginx -t은 syntax is ok 및 test is successful를 출력합니다. curl 호출에는 인증서가 포함되어 있지 않으므로, No required SSL certificate was sent 본문과 함께 400 Bad Request 응답이 돌아와야 합니다. 이는 nginx가 자체 게이트에서 요청을 거부했음을 의미하며, 설정이 적용되었고 애플리케이션까지 요청이 도달하지 않았음을 뜻합니다. 이제 올바르게 시도해 보십시오.
curl --cert certs/alice.crt --key private/alice.key https://admin.example.com/이 명령은 애플리케이션이 제공하는 응답을 반환해야 합니다.
왜 게이트를 server 블록에 두어야 하는가
인증서는 TLS 핸드셰이크 과정에서 교환됩니다. 이때는 nginx가 요청 라인을 읽기 전이므로, nginx는 해당 요청이 어떤 location에 도달할지 알 수 없습니다. ssl_verify_client on;을 location 내부에 배치하는 것은 클라이언트에게 연결 도중 재협상(renegotiate)을 요구하는 것과 같습니다. TLS 1.3에서는 재협상이 제거되었고 HTTP/2는 이를 금지하므로, 현재의 스택에서는 해당 패턴을 사용하면 프롬프트가 뜨는 대신 실패하게 됩니다.
범위 지정은 직접 수행하십시오. server 레벨에서 인증서를 요청한 뒤, location별로 결정하십시오:
ssl_verify_client optional;
location /metrics {
if ($ssl_client_verify != SUCCESS) { return 403; }
proxy_pass http://127.0.0.1:9090;
}
location /healthz {
proxy_pass http://127.0.0.1:8080;
}$ssl_client_verify는 SUCCESS을 담고 있거나, 클라이언트가 아무것도 보내지 않았을 때는 NONE을, 혹은 이유와 함께 FAILED:를 담습니다. optional을 사용하면 nginx는 인증서를 요청하되 인증서가 도착했을 때만 검증을 수행합니다. 이것이 위에서 언급한 공개 /healthz 경로는 작동하게 하면서 /metrics는 닫아둘 수 있게 하는 원리입니다. 전송되었으나 검증에 실패한 인증서는 해당 시점에 nginx에 의해 거부됩니다. 만약 실패한 인증서를 직접 검사하고 싶다면 optional_no_ca을 사용하십시오. 이 경우 직접 작성한 테스트 로직에서 SUCCESS 이외의 모든 값을 거부 처리해야 합니다.
nginx는 이를 위해 비표준 상태 코드를 제공하며, error_page을 사용하면 거부된 방문자에게 단순히 400 오류를 보여주는 대신 설명을 제공할 수 있습니다:
error_page 495 496 = @needcert;
location @needcert {
default_type text/plain;
return 200 "This host requires a client certificate. Ask ops for one.\n";
}495는 클라이언트 인증서 검증 실패를 의미합니다. 496은 클라이언트가 인증서를 제시하지 않았음을 의미합니다. 해당 페이지는 일반 텍스트로 작성하십시오. 페이지를 읽는 사용자는 세션이나 계정이 없는 상태이기 때문입니다.
브라우저에 클라이언트 인증서를 어떻게 설치합니까?
Firefox는 자체 인증서 저장소를 사용합니다. 설정(Settings)으로 이동한 뒤 개인정보 및 보안(Privacy and Security), 인증서 보기(View Certificates), 내 인증서(Your Certificates) 탭을 차례로 선택합니다. 가져오기(Import)를 누른 다음 .p12를 선택하고 암호를 입력하십시오.
Chrome과 Edge는 Windows 및 macOS에서 운영체제의 인증서 저장소를 사용하므로, .p12 파일을 열면 시스템 가져오기 마법사가 시작됩니다. Linux의 경우 Chrome은 홈 디렉터리에 있는 별도의 NSS(network security services) 데이터베이스를 읽으며, 명령줄 도구를 사용하는 것이 가장 확실한 방법입니다.
sudo apt install -y libnss3-tools
pk12util -d sql:$HOME/.pki/nssdb -i alice.p12이후 사이트를 로드하면 브라우저가 어떤 인증서를 전송할지 묻습니다. Chrome은 해당 선택 사항을 브라우저 세션이 유지되는 동안 기억하므로, 다시 질문을 받으려면 브라우저를 재시작해야 합니다. 인증서는 한 대의 기기에 있는 하나의 브라우저 프로필에만 저장됩니다. 따라서 Firefox에 가져온 인증서는 Chrome에서 보이지 않으며, 두 브라우저 모두 휴대폰에서는 확인할 수 없습니다.
curl --cert를 이용한 테스트
curl은 수행한 작업을 보고하므로 디버깅에 유용합니다.
curl -v --cert certs/alice.crt --key private/alice.key https://admin.example.com/인증서와 키를 하나의 PEM 파일로 합친 뒤 --cert alice.pem 옵션으로 전달할 수 있습니다. 키에 암호(passphrase)가 설정되어 있다면 curl이 입력을 요구합니다. --cert alice.pem:passphrase 옵션도 사용할 수 있지만, 이 경우 셸 히스토리에 암호가 남으므로 프롬프트 입력을 권장합니다.
Nginx 설정을 탓하기 전에 다음 두 가지 확인을 수행해야 합니다. 첫째, 인증서와 키가 쌍을 이루는지 확인합니다.
openssl x509 -noout -pubkey -in certs/alice.crt | openssl sha256
openssl pkey -pubout -in private/alice.key | openssl sha256두 해시값이 동일하면 파일이 서로 일치하는 것입니다. 해시값이 다르면 서로 다른 파일이 섞인 것이며, 클라이언트는 이 원인을 명확히 알려주지 않습니다.
둘째, 서버가 CA를 요청하고 있는지 확인합니다.
openssl s_client -connect admin.example.com:443 -servername admin.example.com </dev/null출력 내용에서 Acceptable client certificate CA names 블록을 찾고, 그 안에 본인의 CA subject가 있는지 확인하십시오. 해당 블록이 아예 없다면 Nginx가 응답한 서버 블록에서 인증서를 요청하지 않는 것입니다. 이 경우 설정 지시어가 다른 서버 블록(주로 default 서버)에 적용되었을 가능성이 높습니다.
클라이언트 CN을 애플리케이션으로 전달하기
인증서는 호출자가 누구인지 증명하지만, 프록시 뒤에 있는 애플리케이션은 TLS 계층을 직접 볼 수 없습니다. 따라서 nginx가 해당 이름을 전달해야 합니다.
map $ssl_client_s_dn $client_cn {
default "";
"~,?CN=(?<cn>[^,]+)" $cn;
}$ssl_client_s_dn는 RFC 2253 형식의 주체 식별 이름(subject distinguished name)을 담고 있으며, 이는 CN=alice,O=Example Ops와 같은 형태입니다. map은 CN 필드를 추출하여 $client_cn에 저장합니다. CN 내부의 쉼표는 해당 형식에서 이스케이프 처리되며 위에서 사용한 간단한 정규 표현식은 이를 처리하지 못하므로, CN을 단순한 사용자 이름으로 유지하십시오.
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Client-Cert-CN $client_cn;
proxy_set_header X-Client-Cert-Serial $ssl_client_serial;
}proxy_set_header은 호출자가 보낸 동일한 이름의 헤더를 모두 대체하므로, 누구도 이 위치를 통해 X-Client-Cert-CN을 위조할 수 없습니다. 이를 유지하기 위한 두 가지 조건이 있습니다. nginx는 내부 레벨에 정의된 내용이 없을 때만 외부 레벨로부터 proxy_set_header를 상속받습니다. 따라서 proxy_set_header 행이 하나라도 포함된 두 번째 location 블록은 그 위에 설정된 모든 헤더를 무시하게 됩니다. 또한 애플리케이션은 nginx를 통해서만 접근할 수 있어야 합니다. 즉, 애플리케이션을 0.0.0.0가 아닌 127.0.0.1에 바인딩해야 합니다. 공개 포트에 노출된 앱은 인터넷에서 직접 위조된 헤더를 읽을 수 있기 때문입니다. 이에 대한 프록시 측 설정은 nginx 리버스 프록시 설정 줄 단위 설명에서 다룹니다. 애플리케이션이 이름 대신 인증서 전체를 필요로 한다면, $ssl_client_escaped_cert을 사용하여 URL 인코딩된 안전한 형태로 헤더에 담아 전달할 수 있습니다.
클라이언트 인증서 하나를 폐기하려면 어떻게 해야 합니까?
직원이 퇴사하거나 노트북을 분실하는 경우가 발생합니다. 이때 해당 인증서 하나만 폐기하면 다른 사용자들은 계속 서비스를 이용할 수 있습니다. 이것이 바로 사용자별로 인증서를 개별 발급하는 이유입니다.
cd ~/client-ca
openssl ca -config openssl.cnf -revoke certs/alice.crt
openssl ca -config openssl.cnf -gencrl -out crl.pem첫 번째 명령어는 index.txt 파일에서 해당 일련번호의 상태를 V에서 R으로 변경합니다. 두 번째 명령어는 폐기된 일련번호 목록이 담긴 서명된 파일인 인증서 폐기 목록(CRL)을 생성합니다. 이 파일을 배포하고 nginx 설정의 다른 지시어와 함께 ssl_crl /etc/nginx/client-ca.crl;을 사용하여 지정하십시오.
scp crl.pem user@admin.example.com:/tmp/client-ca.crl
ssh user@admin.example.com 'sudo install -m 644 /tmp/client-ca.crl /etc/nginx/client-ca.crl && sudo nginx -t && sudo systemctl reload nginx'여기서 모든 사용자의 접속이 차단되는 함정이 있습니다. CRL에는 default_crl_days에 의해 설정된 nextUpdate 날짜가 포함되며, 위 설정에서는 30일로 지정되어 있습니다. 이 날짜가 지나면 OpenSSL은 해당 목록을 만료된 것으로 간주하며, 폐기된 인증서뿐만 아니라 모든 클라이언트 인증서에 대해 CRL has expired 오류를 발생시키며 검증에 실패합니다. nginx는 설정을 불러올 때 파일을 읽으므로, 디스크에 새로운 CRL을 저장해도 설정을 다시 불러오기(reload) 전까지는 아무런 변화가 없습니다. 30일이라는 기간 내에 여유 있게, 예를 들어 매주 정기적으로 CRL을 재생성하고 다시 불러오십시오. 복사하기 전에 날짜를 확인하는 것이 좋습니다.
openssl crl -in crl.pem -noout -lastupdate -nextupdate사용자 수가 적다면 더 간단한 방법이 있습니다. CA를 직접 운영 중이므로, nginx에서 CRL 메커니즘을 거치지 않고 특정 일련번호를 직접 거부할 수 있습니다.
map $ssl_client_serial $revoked {
default 0;
"1002" 1;
}이 설정을 location 블록 내의 if ($revoked) { return 403; }과 함께 사용하십시오. 이 방법은 만료 날짜를 신경 쓸 필요가 없습니다. 다만 설정이 외부로 전달되지 않으므로, 동일한 CA를 신뢰하는 다른 시스템은 해당 폐기 사실을 알 수 없습니다. 하나의 애플리케이션 앞에 하나의 nginx만 있는 환경이라면 이 방법이 가장 정직하고 간단한 해결책입니다. 게이트웨이가 여러 개로 늘어나면 그때 CRL 방식으로 전환하십시오.
클라이언트 인증서의 유효 기간은 어느 정도가 적당한가?
클라이언트 인증서의 유효 기간은 1년으로 설정하십시오. 재발급 작업이 번거롭지 않다면 그보다 짧게 설정해도 좋습니다. 인증서 만료는 조용히 실패하는 문제인데, 사용자에게 미리 경고를 보내는 기능이 없기 때문입니다. 사용자가 어느 날 아침 패널에 접속하면 Nginx가 연결을 거부하고, 브라우저는 자체적인 오류 메시지를 표시합니다. 이때 브라우저가 '만료됨'이라는 단어를 명확히 알려주는 경우는 드뭅니다. CA 인증서의 유효 기간은 10년으로 유지하고, 만료 날짜를 반드시 확인할 수 있는 곳에 기록해 두십시오. CA 인증서가 만료되면 해당 CA에서 발급한 모든 인증서가 같은 날부터 검증에 실패하기 때문입니다.
다음 두 명령어를 사용하면 만료 시점을 미리 파악할 수 있습니다.
openssl x509 -in certs/alice.crt -noout -subject -serial -enddate
awk -F'\t' '{print $1, $2, $4}' ~/client-ca/index.txtindex.txt의 첫 번째 열은 상태를 나타냅니다. V은 유효, R는 폐기, E는 만료를 의미합니다. 두 번째 열은 YYMMDDHHMMSSZ 형식의 만료일이며, 네 번째 열은 일련번호입니다. 해당 파일은 누가 어떤 인증서를 보유하고 있는지 기록하는 유일한 자료이므로, CA 키와 함께 백업하고 둘 다 기밀로 취급하십시오.
갱신은 기존 인증서를 연장하는 것이 아니라 새로운 인증서를 발급하는 과정입니다. 새로운 키와 CSR(certificate signing request)을 생성하여 서명한 뒤 사용자에게 전달하십시오. 사용자가 새 인증서가 정상적으로 작동함을 확인하면 기존 인증서를 폐기하십시오.
mTLS가 보호하는 범위와 그렇지 않은 범위
mTLS는 인증되지 않은 접근을 차단합니다. 호스트 이름을 찾아낸 스캐너는 핸드셰이크 과정에서 거부되므로, HTTP 요청을 보내거나 로그인 폼을 확인하거나 탈취한 비밀번호를 시도할 기회조차 얻지 못합니다. 크리덴셜 스터핑(credential stuffing) 공격은 시도할 대상이 사라집니다. 애플리케이션 로그인 흐름의 취약점은 인증서가 없는 공격자에게는 도달 불가능한 영역이 됩니다. 또한 사람들이 채팅창에 실수로 붙여넣는 공유 비밀(shared secret) 문제도 해결됩니다. 개인 키는 파일 형태이므로 실수로 복사하기가 어렵기 때문입니다.
mTLS가 해결하지 못하는 것은 클라이언트가 이미 침해된 경우입니다. 노트북에 설치된 악성코드는 키 파일을 가지고 있으며, 사용자가 비밀번호를 입력하는 순간 암호도 탈취합니다. 서버 입장에서 공격자는 합법적인 사용자와 똑같이 보입니다. 인증서는 파일의 소유권을 증명할 뿐, 실제 사용자가 조작 중인지까지는 보장하지 않기 때문입니다. .p12 비밀번호와 전체 디스크 암호화는 여전히 중요한 보안 수단입니다.
또한 mTLS는 권한 부여(authorization)를 대체하지 않습니다. $client_cn 값을 확인하여 적절한 조치를 취하지 않는다면, 유효한 인증서를 가진 모든 사용자는 해당 서버 블록이 제공하는 모든 자원에 접근할 수 있습니다. 기본적으로 두 인증서 소지자는 동일한 접근 권한을 갖습니다.
mTLS는 오직 Nginx를 통과하는 경로만 보호합니다. 만약 애플리케이션이 공개 포트에서 직접 수신 대기 중이라면, 앞단에 설정한 mTLS는 장식에 불과합니다. 애플리케이션을 127.0.0.1에 바인딩하고 해당 포트의 방화벽을 닫아두어야 합니다. 같은 서버로 들어오는 또 다른 통로인 SSH 역시 동일한 주의가 필요하며, 이에 관한 내용은 VPS의 SSH 접근 보안 강화에서 다룹니다.
마지막 한 가지 제약 사항은 mTLS를 적용하는 당일에 발생합니다. 인증서를 제시할 수 없는 모든 서비스는 작동을 멈춥니다. 가동 시간 모니터링 도구, 결제 제공업체의 웹훅, RSS 리더, 인증서 저장소에 접근할 수 없는 모바일 앱 등이 이에 해당합니다. ssl_verify_client on 설정을 적용하기 전에 이러한 서비스들을 먼저 고려하십시오. 실패 시 모든 연결이 차단되며, 외부 서비스 측에서는 아무런 응답을 받지 못하게 됩니다.
클라이언트 연결이 거부될 때 클라이언트의 보고 내용을 확인하십시오
거부된 클라이언트가 표시하는 메시지는 브라우저, curl 버전, 그리고 하위 TLS 라이브러리에 따라 달라집니다. 따라서 다른 곳에 기록된 메시지와 비교하기보다는 본인의 클라이언트가 출력하는 내용을 직접 확인해야 합니다. 유용한 세부 정보는 서버 쪽에 있습니다.
sudo tail -n 50 /var/log/nginx/error.log거부된 인증서는 client SSL certificate verify error을 포함하는 줄과 그 뒤에 이어지는 OpenSSL의 거부 사유를 남깁니다. 그 사유가 바로 조치를 취해야 할 핵심 정보입니다. 보통은 몇 가지 경우 중 하나입니다. 인증서가 ssl_client_certificate에 지정된 파일과 다른 CA에서 발급되었거나, 인증서의 유효 기간이 지났거나, 서버의 CRL이 nextUpdate을 넘겨서 특정 클라이언트가 아닌 모든 클라이언트의 연결을 거부하는 경우입니다.
브라우저가 인증서를 전혀 제시하지 않는다면, 문제는 검증 단계보다 앞선 곳에 있습니다. nginx는 핸드셰이크 과정에서 허용 가능한 발급자(issuer) 이름을 전송하는데, 브라우저가 자신의 저장소에서 일치하는 항목을 찾지 못하면 제시할 인증서가 없게 됩니다. .p12를 실제로 사용 중인 브라우저 프로필에 다시 가져오기(import) 하십시오.
언급할 가치가 있는 사례가 하나 더 있습니다. CA가 서명한 인증서가 아닌 단독 자체 서명(self-signed) 클라이언트 인증서로 테스트했다면, nginx는 CA 파일과 서명을 대조하여 확인하므로 검증을 통과할 수 없습니다. 자체 서명 인증서를 만드는 과정은 Ubuntu에서 자체 서명 인증서 생성하기와 동일합니다. mTLS는 단지 CA가 해당 인증서에 서명하는 추가 단계가 필요할 뿐입니다.
FAQ
mTLS를 사용하면 Let's Encrypt 인증서가 여전히 필요한가요?
네, 필요합니다. 두 인증서는 서로 관련이 없습니다. 서버는 브라우저가 호스트 이름을 신뢰할 수 있도록 자체 인증서를 제시해야 하며, 이 인증서는 브라우저가 이미 신뢰하는 CA로부터 발급받아야 합니다. 클라이언트 CA는 별도의 사설 체인이며, 오직 접속자를 확인하는 용도로만 사용됩니다. ssl_client_certificate를 설정해도 nginx가 제시하는 인증서에는 아무런 변화가 없으며, 이 설정이 Let's Encrypt 체인을 가리켜서는 안 됩니다.
브라우저에서 인증서를 선택하라는 메시지가 왜 뜨지 않나요?
nginx는 핸드셰이크 과정에서 ssl_client_certificate에 지정된 파일로부터 생성된 허용 가능한 발급자 목록을 전송합니다. 브라우저는 이 목록에 포함된 발급자가 서명한 인증서만 제시합니다. 따라서 메시지가 뜨지 않는다면 브라우저에 해당 CA의 인증서가 없다는 뜻입니다. 다른 브라우저 프로필에 가져왔거나, 서버에 설치된 CA와 다른 CA로 서명된 인증서일 수 있습니다. openssl s_client -connect admin.example.com:443을 실행하여 출력 결과에서 서버가 실제로 요구하는 클라이언트 인증서 CA 이름을 확인하십시오.
특정 URL에서만 클라이언트 인증서를 요구할 수 있나요?
location 내부의 ssl_verify_client on 설정으로는 불가능합니다. 인증서는 핸드셰이크 단계에서 교환되는데, 이때는 nginx가 요청 경로를 알지 못하기 때문입니다. 이를 우회하기 위한 재협상(renegotiation)은 TLS 1.3에서 제거되었고 HTTP/2에서는 금지되어 있습니다. 서버 블록에 ssl_verify_client optional;을 설정한 뒤, 보호하려는 각 location 블록에서 $ssl_client_verify을 검사하여 값이 SUCCESS가 아닐 경우 403을 반환하도록 구성하십시오.
특정 사용자의 접근 권한을 어떻게 취소하나요?
openssl ca -revoke을 사용하여 해당 인증서를 취소하고, openssl ca -gencrl로 목록을 다시 생성한 뒤 서버에 복사하십시오. 그 후 nginx를 리로드하여 새 파일을 읽게 하면 됩니다. 다른 사용자에게는 영향이 없으며, 이는 공유 인증서가 아닌 개인별 인증서를 사용할 때만 유효합니다. CRL의 nextUpdate 날짜를 확인하십시오. CRL이 만료되면 취소된 사용자뿐만 아니라 모든 클라이언트의 검증이 실패하게 됩니다.
mTLS가 로그인 페이지를 대체할 수 있나요?
접근 제어 측면에서는 가능합니다. 인증서가 없으면 애플리케이션에 도달할 수 없으므로 공격할 폼도, 추측할 비밀번호도 존재하지 않게 됩니다. 하지만 애플리케이션 내부의 사용자 식별 수단으로는 부족합니다. 인증서는 호출자가 키 파일을 가지고 있음을 증명할 뿐이므로, 노트북을 도난당하면 타인이 유효한 사용자로 간주됩니다. CN 값을 업스트림으로 전달하고 애플리케이션이 기존에 보유한 계정 및 권한 체계를 유지하면서, 인증서는 그 앞단의 관문으로 활용하십시오.