Published on

Nginx에서 JWT 검증 실패 401 원인 7가지

Authors

서버가 401을 반환하면 대부분은 애플리케이션(업스트림)에서 인증 실패로 거절한 결과지만, 체감상 “Nginx에서 JWT 검증 실패”처럼 보이는 경우가 많습니다. 이유는 간단합니다. Nginx가 앞단에서 요청을 받아 헤더를 바꿔치기하거나(혹은 누락시키거나), 프록시/캐시/리다이렉트를 만들면서 JWT가 유효해도 업스트림이 다른 요청으로 인식해 버리기 때문입니다.

이 글은 다음 두 가지 상황을 모두 포함해 다룹니다.

  • Nginx에서 직접 JWT를 검증하는 구성(예: auth_request, OpenResty/Lua, ngx_http_auth_jwt_module류 서드파티 모듈)
  • Nginx는 프록시만 하고, JWT 검증은 업스트림(API, 게이트웨이, Keycloak/OPA 등)에서 수행하지만 Nginx 설정 때문에 401이 유발되는 구성

또한 OIDC/리다이렉트 플로우를 함께 쓰는 환경이라면, 인증 경계가 꼬이며 401이 반복되는 경우가 많습니다. 비슷한 맥락의 문제는 Keycloak OIDC 로그인 무한 리다이렉트 해결 가이드도 함께 참고하면 진단 속도가 빨라집니다.

진단 전 1분 체크: “어디서” 401이 나왔나

먼저 401이 Nginx에서 생성된 것인지, 업스트림이 생성한 것인지 구분해야 합니다.

  • Nginx가 생성한 401은 보통 server 응답 헤더가 Nginx로 잡히고, 바디가 매우 짧거나 기본 에러 페이지 형태입니다.
  • 업스트림이 생성한 401은 JSON 에러 바디(예: {"code":"UNAUTHORIZED"...})가 있거나, WWW-Authenticate가 애플리케이션 스타일로 붙는 경우가 많습니다.

Nginx 액세스 로그에 업스트림 상태 코드를 남기면 구분이 쉬워집니다.

log_format main_ext '$remote_addr - $request '
                    'status=$status upstream_status=$upstream_status '
                    'uri=$uri args=$args '
                    'auth="$http_authorization" '
                    'ua="$http_user_agent"';

access_log /var/log/nginx/access.log main_ext;

여기서 status=401 upstream_status=가 비어 있으면 Nginx가 직접 401을 만들었을 가능성이 크고, status=401 upstream_status=401이면 업스트림이 401을 만든 것입니다.

원인 1) Authorization 헤더가 업스트림으로 전달되지 않음

가장 흔한 케이스입니다. 클라이언트는 Authorization: Bearer ...를 보냈는데 업스트림에서는 헤더가 비어 있어 401을 반환합니다.

자주 발생하는 이유

  • proxy_set_header Authorization ...를 잘못 설정해 덮어쓰기
  • 인증 서브리퀘스트(auth_request)를 붙이면서 메인 요청의 헤더 전달을 착각
  • 특정 location 블록에서만 헤더를 전달하고, 다른 경로는 누락
  • CDN/ALB/WAF가 Authorization 헤더를 제거(특히 보안 정책)

해결 예시

업스트림으로 Authorization을 명시적으로 전달합니다.

location /api/ {
  proxy_pass http://api_upstream;
  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;

  # 핵심: Authorization 헤더를 그대로 전달
  proxy_set_header Authorization $http_authorization;
}

빠른 확인 방법

Nginx 로그에 $http_authorization을 남기고, 업스트림에서도 수신 헤더를 덤프해 비교하세요. 업스트림이 Spring이라면 필터에서 Authorization을 로깅하거나, 임시로 에코 엔드포인트를 두는 방식이 실용적입니다.

원인 2) auth_request 서브리퀘스트에서 토큰이 누락됨

Nginx의 auth_request는 메인 요청과 별개로 내부 서브리퀘스트를 만들어 인증 엔드포인트를 호출합니다. 이때 서브리퀘스트로 Authorization이 자동 전달되지 않는 구성/버전/설정 조합이 있어, 인증 엔드포인트가 항상 401을 내고 메인 요청도 차단됩니다.

증상

  • /auth 같은 내부 인증 엔드포인트가 항상 401
  • 클라이언트는 토큰을 보냈는데도 인증이 실패

해결 예시

인증 서브리퀘스트에 Authorization을 전달하고, 성공 시 필요한 헤더를 메인 요청으로 넘깁니다.

location /api/ {
  auth_request /_auth;
  auth_request_set $user $upstream_http_x_auth_user;
  proxy_set_header X-Auth-User $user;

  proxy_pass http://api_upstream;
  proxy_set_header Authorization $http_authorization;
}

location = /_auth {
  internal;
  proxy_pass http://auth_upstream/verify;

  # 서브리퀘스트에 토큰 전달
  proxy_set_header Authorization $http_authorization;
  proxy_set_header Content-Length "";
  proxy_set_header X-Original-URI $request_uri;
}

인증 서버(예: OPA, custom auth service)가 X-Auth-User 같은 헤더를 내려주면 auth_request_set으로 꺼내 메인 요청에 주입할 수 있습니다.

원인 3) Bearer 토큰 포맷/인코딩 깨짐(공백, 줄바꿈, 따옴표)

클라이언트/프록시/게이트웨이 중간에서 Authorization 값이 변형되어 JWT 파싱이 실패하는 경우입니다.

흔한 패턴

  • Authorization: Bearer 뒤에 공백이 2개 이상
  • 값이 따옴표로 감싸짐(예: "Bearer ...")
  • 줄바꿈이 섞임(특히 환경변수에서 토큰을 주입하거나, 스크립트로 헤더를 만들 때)

재현/검증

curl로 원본 요청을 그대로 보내며 비교합니다.

curl -i \
  -H "Authorization: Bearer YOUR.JWT.HERE" \
  https://example.com/api/me

Nginx는 일반적으로 헤더 값을 그대로 전달하지만, 중간 장비(CDN/WAF)나 애플리케이션의 헤더 파서가 엄격하면 미세한 포맷 차이로도 401이 납니다.

대응

  • 클라이언트에서 Authorization 생성 로직을 통일
  • 프록시 체인에서 Authorization 헤더가 변형되는지(특히 보안 장비) 확인
  • 업스트림 JWT 라이브러리의 허용 포맷(대소문자, 공백 처리)을 확인

원인 4) 알고리즘/키 불일치(특히 RS256 vs HS256, JWKS 회전)

JWT는 서명 검증이 핵심이므로, 다음 중 하나만 어긋나도 즉시 401입니다.

  • 토큰 alg가 기대와 다름(예: 클라이언트는 RS256, 서버는 HS256으로 검증)
  • 키가 바뀌었는데(JWKS rotation) 검증 서버/모듈이 캐시된 키를 계속 사용
  • kid가 있는 토큰인데 JWKS에서 해당 kid를 찾지 못함

실무 팁

  • 토큰 헤더의 kid, alg를 먼저 확인하세요. (토큰을 디코드하되, 서명 검증 없이 헤더만 확인)
  • Keycloak 같은 IdP를 쓰면 JWKS 엔드포인트 캐시 정책이 중요합니다.

OpenResty(Lua)로 JWKS 캐시 갱신 예시(개념)

아래는 “키 회전 시 캐시 때문에 401이 날 수 있다”는 점을 보여주는 간단 예시입니다.

# OpenResty 환경 가정
lua_shared_dict jwks_cache 10m;

location = /_jwks_refresh {
  internal;
  content_by_lua_block {
    -- JWKS를 주기적으로 가져와 캐시에 저장하는 로직을 둔다(개념)
    -- 실제 구현에서는 ssl 검증, 타임아웃, 백오프, JSON 파싱 등이 필요
  }
}

운영에서는 검증 컴포넌트(게이트웨이/미들웨어)의 JWKS 캐시 TTL회전 시 동작(실패 시 재시도/강제 갱신)을 명시적으로 확인해야 합니다.

원인 5) 시간 동기화 문제로 exp, nbf, iat 검증 실패

JWT의 클레임 검증은 서버 시간이 정확하다는 전제가 있습니다.

  • exp가 지났다고 판단
  • nbf가 아직이라고 판단
  • iat가 미래라고 판단

특히 컨테이너/VM에서 NTP가 어긋나면 “특정 노드에서만 401” 같은 형태로 나타납니다.

진단 체크리스트

  • 노드별 시간을 비교(클러스터라면 더 중요)
  • JWT 검증 라이브러리의 clock skew 허용치 확인(예: 30초)

해결

  • NTP/chrony 정상화
  • 검증 로직에 합리적인 skew 허용(보안 요구사항과 트레이드오프)

디스크가 꽉 차서 chrony 로그/상태 파일이 깨지거나 시스템 서비스가 비정상인 경우도 있으니, 인프라가 이상하면 No space left on device인데 용량 남을 때 - inode 0% 해결처럼 “겉보기와 다른 장애”도 함께 점검하는 편이 좋습니다.

원인 6) 프록시 캐시/리다이렉트로 인증이 섞임(401 캐싱 포함)

인증이 필요한 응답을 캐시하면, 다른 사용자도 동일한 401을 받거나(혹은 반대로 남의 200을 받는) 심각한 문제가 생깁니다.

대표 증상

  • 토큰을 바꿔도 계속 401
  • 특정 POP/노드에서만 401이 지속
  • 로그인 직후엔 되다가 갑자기 전부 401

잘못된 캐시 예시

Authorization을 캐시 키에 포함하지 않거나, 401을 캐시해버리는 경우입니다.

안전한 기본 가이드

  • 인증이 필요한 경로에서는 캐시를 꺼두는 것이 안전합니다.
location /api/ {
  proxy_pass http://api_upstream;

  # 인증 경로는 캐시 비활성 권장
  proxy_no_cache 1;
  proxy_cache_bypass 1;

  proxy_set_header Authorization $http_authorization;
}

CDN을 함께 쓴다면 CDN 레벨에서도 Authorization 존재 시 캐시 우회가 기본 안전장치입니다.

원인 7) CORS/프리플라이트(OPTIONS) 요청이 인증에 걸려 401

브라우저 환경에서 흔합니다. 프리플라이트 요청은 보통 OPTIONS로 날아가며, 이 요청에는 Authorization 헤더가 없을 수 있습니다. 그런데 서버가 OPTIONS에도 JWT를 요구하면 브라우저는 실제 요청을 보내기 전에 막혀버립니다.

증상

  • 개발자 도구 네트워크 탭에서 OPTIONS가 401
  • 실제 GET/POST는 아예 전송되지 않음

해결 예시: OPTIONS는 인증 없이 통과

location /api/ {
  if ($request_method = OPTIONS) {
    add_header Access-Control-Allow-Origin $http_origin;
    add_header Access-Control-Allow-Methods "GET,POST,PUT,PATCH,DELETE,OPTIONS";
    add_header Access-Control-Allow-Headers "Authorization,Content-Type";
    add_header Access-Control-Allow-Credentials "true";
    return 204;
  }

  proxy_pass http://api_upstream;
  proxy_set_header Authorization $http_authorization;
}

주의할 점은, CORS 헤더는 단순히 Nginx에서 붙이는 것만으로 끝나지 않고(특히 Allow-Credentials), 업스트림/브라우저 정책과 함께 일관되게 맞춰야 한다는 것입니다.

7가지 원인을 빠르게 좁히는 실전 루틴

운영에서 시간을 아끼려면 다음 순서가 효율적입니다.

  1. 업스트림 상태 확인: 로그에 $upstream_status를 남겨 Nginx 생성 401인지 구분
  2. 헤더 전달 확인: $http_authorization 로깅, 업스트림 수신 헤더 비교
  3. auth_request 여부 확인: 인증 서브리퀘스트로 토큰이 전달되는지 점검
  4. 토큰 포맷 점검: 공백/따옴표/줄바꿈, Bearer 접두어
  5. 키/알고리즘 점검: alg, kid, JWKS 회전/캐시 TTL
  6. 시간 점검: 노드 시간, skew 허용치, 특정 노드 편향
  7. 캐시/CORS 점검: 401 캐싱 여부, OPTIONS 401 여부

부록: 디버깅에 유용한 Nginx 설정 스니펫

민감정보 노출을 피하면서도 원인 파악에 도움이 되는 최소한의 로깅 예시입니다. 토큰 전체를 남기기보다 “존재 여부”만 남기는 방식이 안전합니다.

map $http_authorization $has_auth {
  default 1;
  "" 0;
}

log_format jwt_diag '$remote_addr $request '
                   'status=$status upstream=$upstream_status '
                   'has_auth=$has_auth '
                   'ref="$http_referer"';

access_log /var/log/nginx/jwt_diag.log jwt_diag;

이렇게만 해도 “특정 경로에서만 has_auth=0” 같은 패턴이 바로 보이고, 원인 1~2번으로 빠르게 수렴합니다.

마무리

Nginx 앞단에서 JWT 401이 터질 때는 토큰 자체가 나쁜 경우도 있지만, 실제로는 헤더 전달/서브리퀘스트/캐시/시간 같은 인프라 레이어에서 발생하는 경우가 더 많습니다. 위 7가지를 순서대로 확인하면, 재현이 어려운 간헐적 401도 대부분 원인을 특정할 수 있습니다.

OIDC 로그인 플로우와 결합되어 리다이렉트/세션 경계가 꼬인 상황이라면, 401 자체보다 “왜 인증 상태가 유지되지 않는지”를 같이 봐야 합니다. 그런 케이스는 Keycloak OIDC 로그인 무한 리다이렉트 해결 가이드가 특히 도움이 됩니다.