Published on

nginx mTLS+OAuth2 프록시 401/403 해결 가이드

Authors

서버 앞단에서 nginxmTLS를 강제하고, 그 뒤에 oauth2-proxy(또는 유사한 OAuth2 인증 프록시)를 붙여서 애플리케이션을 보호하는 구성은 보안적으로 매우 강력합니다. 다만 실제 운영에서는 401403이 빈번하게 발생합니다. 문제는 “어디서 거절했는지”가 한눈에 보이지 않는다는 점입니다. nginx 단계인지, oauth2-proxy 단계인지, 업스트림 애플리케이션 단계인지에 따라 원인과 해결책이 완전히 달라집니다.

이 글은 401/403TLS 계층 → 인증 프록시 계층 → 업스트림 계층으로 나눠서, 로그와 설정을 기준으로 빠르게 원인을 좁히는 방법을 정리합니다.

401 vs 403를 먼저 분리하기

  • 401 Unauthorized: “인증 정보가 없거나(미로그인), 인증 정보를 받아들이지 못함(토큰/세션 무효)”에 가깝습니다.
  • 403 Forbidden: “인증은 되었지만 권한이 없거나, 정책상 차단됨”에 가깝습니다.

하지만 이 규칙이 항상 맞는 것은 아닙니다. 예를 들어 nginxmTLS 클라이언트 인증서가 없으면 400 또는 495/496(nginx 비표준)로 떨어질 수 있고, oauth2-proxy는 CSRF 쿠키 불일치로 403을 내기도 합니다. 따라서 응답 코드만 보지 말고, 누가 응답했는지를 반드시 확인해야 합니다.

누가 401/403을 반환했는지 확인하는 방법

  1. 브라우저 개발자 도구 또는 curl -vServer 헤더/리다이렉트 위치를 확인합니다.

  2. 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: nginxmTLS를 강제하고, 인증이 통과한 트래픽만 oauth2-proxy로 전달
  • 패턴 B: oauth2-proxy 앞단에 nginx가 있고, nginxauth_requestoauth2-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_verifyFAILED가 됩니다.

  • 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-proxy202(통과) 또는 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에서 항상 동일한 HostX-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-proxyX-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가 전달되지 않아 항상 401
  • X-Forwarded-Proto가 내부 스킴으로 전달되어 redirect URL이 http로 생성, 브라우저가 쿠키를 버리거나 혼합 콘텐츠 이슈 발생
  • --cookie-domain 미설정으로 서브도메인 이동 시 세션 쿠키 미전달
  • 앱이 Authorization 헤더를 기대하는데, oauth2-proxy가 토큰을 전달하지 않음
  • mTLS DN/SAN과 OAuth2 claim 매핑 정책이 맞지 않아 앱에서 403

마무리: 401/403을 “계층별로” 잘라보면 답이 보인다

nginx mTLS + oauth2-proxy 조합에서의 401/403은 대개 “인증이 실패했다”가 아니라, 어느 계층에서 어떤 컨텍스트(쿠키, 헤더, 스킴, 호스트)가 끊겼는지의 문제입니다.

  1. nginx에서 upstream_status$ssl_client_verify를 로그로 남겨 “mTLS 단계 vs 업스트림 단계”를 먼저 분리하고,
  2. auth_request 서브요청에 쿠키/포워딩 헤더를 제대로 전달하며,
  3. 앱이 기대하는 사용자/토큰/역할 정보를 어떤 헤더로 넘길지 명확히 합의하면,

대부분의 401/403은 재현 가능하게 해결됩니다.

추가로, 프록시/인증 레이어 변경 후 서비스가 비정상 재시작을 반복하거나 설정 반영이 꼬인 경우에는 운영 진단 루틴을 정리한 글도 도움이 됩니다: systemd 서비스가 자꾸 재시작될 때 7단계 진단