- Published on
nginx mTLS+OAuth2 프록시 401/403 해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 앞단에서 nginx로 mTLS를 강제하고, 그 뒤에 oauth2-proxy(또는 유사한 OAuth2 인증 프록시)를 붙여서 애플리케이션을 보호하는 구성은 보안적으로 매우 강력합니다. 다만 실제 운영에서는 401과 403이 빈번하게 발생합니다. 문제는 “어디서 거절했는지”가 한눈에 보이지 않는다는 점입니다. nginx 단계인지, oauth2-proxy 단계인지, 업스트림 애플리케이션 단계인지에 따라 원인과 해결책이 완전히 달라집니다.
이 글은 401/403을 TLS 계층 → 인증 프록시 계층 → 업스트림 계층으로 나눠서, 로그와 설정을 기준으로 빠르게 원인을 좁히는 방법을 정리합니다.
401 vs 403를 먼저 분리하기
401 Unauthorized: “인증 정보가 없거나(미로그인), 인증 정보를 받아들이지 못함(토큰/세션 무효)”에 가깝습니다.403 Forbidden: “인증은 되었지만 권한이 없거나, 정책상 차단됨”에 가깝습니다.
하지만 이 규칙이 항상 맞는 것은 아닙니다. 예를 들어 nginx가 mTLS 클라이언트 인증서가 없으면 400 또는 495/496(nginx 비표준)로 떨어질 수 있고, oauth2-proxy는 CSRF 쿠키 불일치로 403을 내기도 합니다. 따라서 응답 코드만 보지 말고, 누가 응답했는지를 반드시 확인해야 합니다.
누가 401/403을 반환했는지 확인하는 방법
브라우저 개발자 도구 또는
curl -v로Server헤더/리다이렉트 위치를 확인합니다.nginx access log에 업스트림 상태를 남깁니다.
log_format upstreamlog '$remote_addr - $host "$request" $status '
'upstream_status=$upstream_status '
'upstream_addr=$upstream_addr '
'request_time=$request_time upstream_time=$upstream_response_time '
'ssl_client_verify=$ssl_client_verify';
access_log /var/log/nginx/access.log upstreamlog;
error_log /var/log/nginx/error.log info;
status=401인데upstream_status가 비어 있으면, 보통nginx가 업스트림에 전달하기 전에 막은 것입니다.status=401이고upstream_status=401이면, 업스트림(대개oauth2-proxy또는 앱)이 반환한 것입니다.
운영 중 서비스가 설정 변경 이후 계속 재시작/재로딩을 반복한다면, 원인 추적 루틴은 이 글도 함께 참고할 만합니다: systemd 서비스가 자꾸 재시작될 때 7단계 진단
기준 아키텍처: nginx 앞단 mTLS + oauth2-proxy
대표적인 패턴은 아래 중 하나입니다.
- 패턴 A:
nginx가mTLS를 강제하고, 인증이 통과한 트래픽만oauth2-proxy로 전달 - 패턴 B:
oauth2-proxy앞단에nginx가 있고,nginx는auth_request로oauth2-proxy의 인증 엔드포인트에 서브요청을 보내서 통과/차단
이 글에서는 운영에서 가장 흔한 **패턴 B(auth_request)**를 중심으로 설명합니다. 이유는 401/403이 가장 많이 발생하는 지점이 auth_request의 헤더/쿠키 전달 및 리다이렉트 구성에서 나오기 때문입니다.
1단계: mTLS에서 막히는 경우(nginx 단계)
증상
- 브라우저에서 바로 연결 실패 또는 인증서 선택 팝업
curl에서 핸드셰이크 단계에서 실패- 응답 코드가
400,495,496, 또는401/403이지만upstream_status가 비어 있음
핵심 체크
1) ssl_verify_client 정책 확인
server {
listen 443 ssl;
ssl_certificate /etc/nginx/tls/server.crt;
ssl_certificate_key /etc/nginx/tls/server.key;
ssl_client_certificate /etc/nginx/tls/ca.crt;
ssl_verify_client on; # 또는 optional / optional_no_ca
}
on: 클라이언트 인증서가 없으면 즉시 차단optional: 인증서가 없어도 통과(대신$ssl_client_verify로 분기 가능)
운영에서는 “특정 경로만 mTLS”가 필요할 때가 많습니다. 이때 서버 블록 전체를 on으로 두면, OAuth2 로그인 리다이렉트나 헬스체크까지 막아서 예상치 못한 401/403처럼 보일 수 있습니다.
해결은 경로별로 분리하거나, optional로 두고 내부에서 차단하는 방식이 안정적입니다.
2) 클라이언트 인증서 체인 문제
클라이언트 인증서가 올바르게 서명되었어도, nginx가 검증할 CA 번들을 잘못 넣으면 $ssl_client_verify가 FAILED가 됩니다.
ssl_client_certificate에는 “클라이언트 인증서를 발급한 CA 체인”이 들어가야 합니다.- 중간 CA가 있는 경우 번들 파일에 체인을 올바른 순서로 포함해야 합니다.
진단용으로 아래처럼 로그에 남기면 원인 파악이 빨라집니다.
log_format mtls '$remote_addr $host $request $status '
'verify=$ssl_client_verify '
'dn="$ssl_client_s_dn" '
'serial=$ssl_client_serial';
access_log /var/log/nginx/mtls.log mtls;
3) 업스트림으로 인증서 정보를 전달해야 하는가
nginx에서 mTLS로 검증만 하고, 앱에서는 “누가 접속했는지”를 알아야 하는 경우가 있습니다. 이때 흔히 X-SSL-Client-DN 같은 헤더를 전달하는데, 누락되면 앱이 자체 권한검사에서 403을 반환할 수 있습니다.
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
주의: 이 헤더는 신뢰 경계 내부에서만 사용해야 합니다. 외부 클라이언트가 임의로 헤더를 넣지 못하도록, nginx에서 기존 값을 덮어쓰는 형태로 설정하는 것이 안전합니다.
2단계: oauth2-proxy 연동에서 401/403이 나는 경우
auth_request 기반 구성에서 nginx는 보호 경로 요청이 들어오면, 백그라운드로 oauth2-proxy의 인증 엔드포인트에 서브요청을 보냅니다. 여기서 oauth2-proxy가 202(통과) 또는 401/403(차단)을 반환하고, nginx가 이를 바탕으로 실제 요청을 허용/거절합니다.
권장 nginx 구성(예시)
아래 예시는 다음을 가정합니다.
- 보호 경로:
/app/ oauth2-proxy엔드포인트:/oauth2/- 인증 확인:
/oauth2/auth
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/nginx/tls/server.crt;
ssl_certificate_key /etc/nginx/tls/server.key;
ssl_client_certificate /etc/nginx/tls/ca.crt;
ssl_verify_client optional;
# oauth2-proxy로 직접 프록시
location /oauth2/ {
proxy_pass http://oauth2-proxy:4180;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 보호 리소스
location /app/ {
auth_request /oauth2/auth;
error_page 401 = /oauth2/sign_in;
# auth_request 결과에서 사용자 정보를 받아 업스트림에 전달
auth_request_set $user $upstream_http_x_auth_request_user;
auth_request_set $email $upstream_http_x_auth_request_email;
proxy_set_header X-Auth-Request-User $user;
proxy_set_header X-Auth-Request-Email $email;
# mTLS 식별도 함께 전달(필요 시)
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
proxy_pass http://app:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
이 구성에서 401/403이 나는 대표 원인은 아래와 같습니다.
원인 A: error_page 401 리다이렉트가 깨짐
증상:
/app/접근 시 로그인 페이지로 가지 않고 401만 반복- 혹은 로그인 성공 후 다시
/oauth2/sign_in으로 되돌아감
체크:
error_page 401 = /oauth2/sign_in;가 있어야 합니다.oauth2-proxy가 외부에서 접근 가능한 “정확한 redirect URL”을 알도록X-Forwarded-Proto,Host가 올바르게 전달되어야 합니다.
oauth2-proxy 실행 옵션에서도 --redirect-url 또는 --cookie-secure 등의 값이 실제 외부 URL과 맞아야 합니다.
원인 B: 쿠키 도메인/경로/SameSite 문제로 세션이 유지되지 않음(401 반복)
증상:
- 로그인은 성공하는데 보호 페이지로 돌아오면 다시 401
- 특히 서브도메인, 다른 도메인, 또는
http에서https로 전환 시 자주 발생
해결 방향:
oauth2-proxy의 쿠키 설정을 명확히 지정합니다.
예시(실행 옵션, 일부):
oauth2-proxy \
--http-address=0.0.0.0:4180 \
--provider=oidc \
--oidc-issuer-url=https://idp.example.com/realms/main \
--client-id=myclient \
--client-secret=... \
--cookie-secret=... \
--cookie-secure=true \
--cookie-samesite=lax \
--cookie-domain=example.com \
--whitelist-domain=.example.com
- 서브도메인 간 공유가 필요하면
--cookie-domain=example.com또는.example.com패턴을 사용합니다. - 크로스사이트 리다이렉트가 강한 환경(임베드, 외부 도메인에서 진입 등)이라면
SameSite=None이 필요할 수 있는데, 이 경우Secure가 필수입니다.
원인 C: auth_request 서브요청에 쿠키/헤더가 전달되지 않음(403 또는 401)
auth_request는 서브요청이기 때문에, 일부 헤더가 기대대로 전달되지 않아 인증이 실패할 수 있습니다.
다음은 oauth2-proxy 문서/운영에서 자주 쓰는 패턴입니다.
location = /oauth2/auth {
internal;
proxy_pass http://oauth2-proxy:4180/oauth2/auth;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header X-Forwarded-Proto $scheme;
# 중요: 원 요청의 쿠키가 auth 서브요청으로 전달되어야 함
proxy_set_header Cookie $http_cookie;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
}
Cookie를 명시적으로 전달하지 않으면, 세션 쿠키를 못 읽어서401이 납니다.X-Forwarded-Uri를 전달하지 않으면, 인증 후 원래 위치로 돌아오지 못해 루프가 생기기도 합니다.
원인 D: CSRF 쿠키 불일치로 403
증상:
- 로그인 콜백에서
403또는 “CSRF token mismatch” 류 로그
주요 원인:
- 여러
nginx인스턴스/여러 도메인에서 쿠키 스코프가 섞임 - L7 로드밸런서 뒤에서
Host/X-Forwarded-Proto가 일관되지 않음
해결:
- 외부에서 보이는 스킴/호스트를 단일화하고,
nginx에서 항상 동일한Host와X-Forwarded-Proto를 전달 oauth2-proxy의--cookie-domain,--whitelist-domain을 실제 접근 도메인에 맞게 정리
3단계: 업스트림 앱에서 403이 나는 경우(권한/헤더 매핑)
mTLS와 OAuth2를 모두 통과했는데도 403이 난다면, 보통 업스트림 앱이 다음 중 하나를 기대합니다.
- 특정 헤더(예:
Authorization,X-User,X-Email,X-Role) - 특정 토큰 포맷(예: JWT를 그대로 전달)
- 특정 mTLS 식별자(DN, SAN, SPIFFE ID 등)
oauth2-proxy가 Authorization 헤더를 넘기지 않아 403
앱이 Authorization: Bearer ...를 필요로 한다면, oauth2-proxy에서 토큰 전달 옵션을 켜야 합니다(버전/설정에 따라 플래그가 다를 수 있음).
대안으로는 nginx에서 auth_request_set으로 oauth2-proxy가 내려주는 헤더를 받아 앱으로 전달합니다.
auth_request_set $access_token $upstream_http_x_auth_request_access_token;
proxy_set_header Authorization "Bearer $access_token";
주의: 실제로 oauth2-proxy가 X-Auth-Request-Access-Token을 내려주도록 설정되어 있어야 합니다. 환경에 따라 --set-authorization-header=true, --pass-access-token=true 같은 옵션 조합이 필요합니다.
mTLS 사용자 식별과 OAuth2 사용자 식별이 불일치
운영에서 흔한 정책은 “mTLS의 클라이언트 인증서 주체(DN 또는 SAN)와 OAuth2의 이메일/서브젝트가 매칭되어야 한다” 입니다. 이 경우 둘 중 하나라도 누락되면 앱은 403을 반환합니다.
nginx가 mTLS 정보를 헤더로 전달하지 않음- 인증서는 갱신되었는데 IdP 계정과 매핑 테이블이 갱신되지 않음
이런 유형은 단순 설정 문제를 넘어 운영 데이터/정책 문제로 이어지므로, 재현 가능한 테스트 케이스를 만들어 두는 것이 좋습니다.
4단계: 재현 가능한 curl 테스트로 빠르게 좁히기
브라우저는 쿠키/리다이렉트/캐시가 섞여서 원인 파악이 느립니다. 아래 curl 시나리오로 계층을 분리하세요.
1) mTLS만 검증
curl -vk https://example.com/app/ \
--cert /path/client.crt \
--key /path/client.key \
--cacert /path/ca.crt
- 여기서 TLS 단계에서 실패하면 OAuth2 이전 문제입니다.
2) oauth2-proxy 인증 엔드포인트 확인
curl -vk https://example.com/oauth2/auth \
--cert /path/client.crt \
--key /path/client.key \
--cacert /path/ca.crt
- 세션 쿠키 없이 호출하면 보통
401이 정상입니다. - 로그인 이후 쿠키를 포함했는데도
401/403이면 쿠키 도메인/경로 또는auth_request쿠키 전달 문제 가능성이 큽니다.
3) 보호 경로에서 업스트림 403인지 확인
nginx 로그에서 upstream_status를 보고 앱이 403을 냈는지 확인합니다. 앱이 냈다면 앱 로그에서 “어떤 claim/role이 없어서 거절했는지”를 확인해야 합니다.
5단계: 로그를 이렇게 남기면 401/403이 빨리 끝난다
nginx: mTLS + auth_request 관측 포인트 추가
log_format authdiag '$remote_addr $host "$request" $status '
'upstream_status=$upstream_status '
'uri=$uri '
'ssl_verify=$ssl_client_verify '
'user=$upstream_http_x_auth_request_user '
'email=$upstream_http_x_auth_request_email '
'reqid=$request_id';
access_log /var/log/nginx/authdiag.log authdiag;
ssl_verify=SUCCESS인데도 401이면 OAuth2 계층을 봐야 합니다.user/email이 비어 있으면oauth2-proxy가 인증을 통과시키지 않았거나, 헤더 매핑이 누락된 것입니다.
oauth2-proxy: 리버스 프록시 헤더 신뢰 설정
로드밸런서/프록시 뒤에 있을 때는 X-Forwarded-*를 신뢰하도록 설정이 필요합니다. 그렇지 않으면 redirect URL이 꼬여서 401 루프가 납니다. oauth2-proxy는 배포 방식에 따라 --reverse-proxy=true 같은 옵션을 쓰는 경우가 많습니다.
6단계: 운영에서 자주 밟는 함정 체크리스트
nginx에서ssl_verify_client on을 걸어두고/oauth2/경로까지 막아 로그인 자체가 불가능auth_request서브요청에Cookie가 전달되지 않아 항상 401X-Forwarded-Proto가 내부 스킴으로 전달되어 redirect URL이http로 생성, 브라우저가 쿠키를 버리거나 혼합 콘텐츠 이슈 발생--cookie-domain미설정으로 서브도메인 이동 시 세션 쿠키 미전달- 앱이
Authorization헤더를 기대하는데,oauth2-proxy가 토큰을 전달하지 않음 - mTLS DN/SAN과 OAuth2 claim 매핑 정책이 맞지 않아 앱에서 403
마무리: 401/403을 “계층별로” 잘라보면 답이 보인다
nginx mTLS + oauth2-proxy 조합에서의 401/403은 대개 “인증이 실패했다”가 아니라, 어느 계층에서 어떤 컨텍스트(쿠키, 헤더, 스킴, 호스트)가 끊겼는지의 문제입니다.
nginx에서upstream_status와$ssl_client_verify를 로그로 남겨 “mTLS 단계 vs 업스트림 단계”를 먼저 분리하고,auth_request서브요청에 쿠키/포워딩 헤더를 제대로 전달하며,- 앱이 기대하는 사용자/토큰/역할 정보를 어떤 헤더로 넘길지 명확히 합의하면,
대부분의 401/403은 재현 가능하게 해결됩니다.
추가로, 프록시/인증 레이어 변경 후 서비스가 비정상 재시작을 반복하거나 설정 반영이 꼬인 경우에는 운영 진단 루틴을 정리한 글도 도움이 됩니다: systemd 서비스가 자꾸 재시작될 때 7단계 진단