- Published on
Nginx에서 JWT 검증 실패 401 원인 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 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가지 원인을 빠르게 좁히는 실전 루틴
운영에서 시간을 아끼려면 다음 순서가 효율적입니다.
- 업스트림 상태 확인: 로그에
$upstream_status를 남겨 Nginx 생성 401인지 구분 - 헤더 전달 확인:
$http_authorization로깅, 업스트림 수신 헤더 비교 auth_request여부 확인: 인증 서브리퀘스트로 토큰이 전달되는지 점검- 토큰 포맷 점검: 공백/따옴표/줄바꿈,
Bearer접두어 - 키/알고리즘 점검:
alg,kid, JWKS 회전/캐시 TTL - 시간 점검: 노드 시간, skew 허용치, 특정 노드 편향
- 캐시/CORS 점검: 401 캐싱 여부,
OPTIONS401 여부
부록: 디버깅에 유용한 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 로그인 무한 리다이렉트 해결 가이드가 특히 도움이 됩니다.