JWT를 JWT Decoder에서 열었을 때 header와 payload가 멀쩡하게 보이는데 API는 계속 401 Unauthorized를 반환하는 경우가 있습니다. 이때 제가 가장 먼저 버리는 생각은 **“디코딩이 되니까 token은 정상일 것”**이라는 가정입니다.
401은 한 가지 원인을 의미하지 않습니다. Authorization header가 잘못됐을 수도 있고, token이 만료됐을 수도 있고, signature가 틀렸거나 issuer/audience가 API 정책과 맞지 않을 수도 있습니다. 그래서 저는 아래 순서를 고정해서 확인합니다.
제가 쓰는 401 디버깅 순서
Authorization header 존재?
↓
Bearer token 형태가 맞나?
↓
JWT가 구조적으로 디코딩되나?
↓
exp / nbf / iat가 합리적인가?
↓
signature가 trusted key로 검증되나?
↓
iss가 expected issuer와 정확히 일치하나?
↓
aud에 현재 API가 포함되나?
↓
token type / scope / role이 맞나?
이 흐름을 지키면 “401이니까 token을 다시 발급해 본다”는 식의 반복 작업보다 원인을 훨씬 빨리 좁힐 수 있습니다.
1. 실제 HTTP 요청부터 확인합니다
JWT 자체를 보기 전에 저는 네트워크 탭이나 서버 로그에서 실제 Authorization header가 어떻게 전송됐는지 확인합니다.
정상적인 예:
Authorization: Bearer eyJ...
제가 보는 흔한 문제는 다음과 같습니다.
Bearer가 빠짐Bearer뒤 공백이 없음- token 앞뒤에 따옴표가 포함됨
- refresh token을 access token 자리에 보냄
- frontend에서 오래된 token을 계속 사용함
- proxy/gateway가 Authorization header를 제거함
- 다른 환경의 token을 production API에 보냄
RFC 6750의 Bearer token 사용 규칙과 실제 요청을 먼저 맞춰보면 JWT cryptography까지 들어가기 전에 해결되는 경우가 많습니다.
2. 구조적으로 디코딩되는지 확인합니다
다음으로 JWT Decoder에서 token을 읽습니다.
제가 확인하는 항목은:
- segment가 일반 signed JWT처럼 3개인지
- header/payload가 valid JSON인지
alg,kid가 무엇인지iss,aud,exp,nbf,iat가 있는지
여기서 정상적으로 디코딩된다는 것은 읽을 수 있다는 뜻일 뿐, 인증에 성공해야 한다는 뜻은 아닙니다.
3. exp와 nbf를 확인합니다
401의 가장 단순한 원인 중 하나는 token lifetime입니다.
exp가 현재 시간보다 과거면 만료된 token입니다. nbf가 현재 시간보다 미래면 아직 사용하면 안 되는 token입니다.
시간 claim을 눈으로 계산하는 대신 저는 JWT Expiry Checker에서 다음을 함께 봅니다.
- expiration time
- issued-at time
- not-before time
- token age
- active window
JWT NumericDate가 초 단위라는 점도 확인합니다. 애플리케이션에서 Date.now()와 직접 비교할 때는 단위 변환이 필요합니다.
4. 서명을 실제로 검증합니다
시간이 정상이라면 다음은 signature입니다.
HS256
expected shared secret이 정확히 같은지 확인합니다. 테스트 환경 secret과 production secret이 뒤바뀌는 문제도 흔합니다.
RS256/PS256/ES256
저는 다음 trust chain을 봅니다.
expected issuer
→ trusted JWKS
→ JWT header의 kid
→ compatible JWK
→ signature verification
JWKS Validator를 사용하면 kid가 key와 매칭되는 것과 signature가 실제로 검증되는 것을 구분해서 볼 수 있습니다.
kid가 맞다고 signature가 맞는 것은 아닙니다.
5. iss는 문자열 exact match로 확인합니다
signature가 정상인데 401이면 issuer mismatch를 확인합니다.
예를 들어 token이:
{
"iss": "https://login.example.com/"
}
라고 하고 API가 기대하는 값이:
https://login.example.com
이라면 trailing slash 차이 때문에 실패할 수 있습니다. issuer는 임의로 normalize하기보다 provider/metadata와 애플리케이션 설정이 정확히 같은 값을 쓰는지 확인합니다.
JWT Issuer Validation 가이드에서 제가 issuer와 key source를 함께 확인하는 이유를 자세히 설명합니다.
6. aud가 현재 API를 대상으로 하는지 확인합니다
audience mismatch도 매우 흔합니다.
예를 들어 browser app을 위한 ID token의 aud가 client ID인데, 그 token을 resource API에 보내면 API는 자신을 audience로 찾지 못해 401을 반환할 수 있습니다.
aud는 문자열 하나일 수도 있고 배열일 수도 있습니다.
{
"aud": [
"https://api.example.com",
"another-resource"
]
}
제가 확인하는 질문은 단순합니다.
이 token은 정말 이 API가 소비하도록 발급된 token인가?
자세한 진단은 JWT Audience Mismatch 가이드를 사용합니다.
7. Access token과 ID token을 섞지 않습니다
둘 다 JWT처럼 보일 수 있지만 목적이 다릅니다.
ID token은 보통 client가 authentication 결과를 이해하기 위한 것이고, access token은 resource server/API 접근에 사용됩니다.
저는 token이 JWT라는 이유만으로 API에 사용할 수 있다고 가정하지 않습니다. issuer 문서와 token type 정책을 확인합니다.
8. 401과 403을 구분합니다
서비스마다 구현이 다르지만 일반적으로 다음 구분이 디버깅에 도움이 됩니다.
- 401: 인증 정보가 없거나 사용할 수 없음
- 403: 신원은 확인됐지만 해당 작업 권한이 부족함
하지만 실제 제품이 이 구분을 완벽히 지키지 않는 경우도 있으므로 HTTP status만 보고 원인을 단정하지 않습니다.
401이라도 scope/role 정책 때문에 그렇게 응답하는 API가 있을 수 있습니다.
9. Scope와 role을 확인합니다
signature, issuer, audience가 모두 정상이어도 권한 claim이 부족할 수 있습니다.
예:
{
"scope": "openid profile read:orders"
}
API가 write:orders를 요구한다면 token 자체는 유효해도 요청을 허용할 수 없습니다.
저는 provider마다 scope 표현과 custom role claim 이름이 다르기 때문에 documentation과 실제 authorization middleware를 함께 확인합니다.
10. Key rotation 직후라면 JWKS cache를 의심합니다
“몇 분 전부터 일부 token만 401”이라면 rotation을 봅니다.
대표적인 패턴:
old token + old kid → 성공
new token + new kid → 실패
이 경우:
- new key가 JWKS에 publish됐는지
- verifier cache가 너무 오래 유지되는지
- unknown
kid가 왔을 때 JWKS를 refresh하는지 - old/new key overlap이 충분한지
확인합니다.
JWT Key Rotation 가이드에서 이 시나리오를 더 자세히 다룹니다.
11. 같은 token을 여러 환경에서 비교합니다
local에서는 성공하고 staging에서만 실패하거나, staging에서 성공하고 production에서만 실패한다면 환경별 설정 차이를 봅니다.
제가 비교하는 값:
- expected issuer
- expected audience
- allowed algorithms
- JWKS URL
- JWKS cache 상태
- system clock
- proxy Authorization forwarding
- required scope/role
token 두 개가 어떻게 달라졌는지 확인하려면 JWT Diff도 유용합니다.
제가 401에서 피하는 해결책
Signature verification을 끈다
문제를 숨길 뿐 trust boundary를 제거합니다.
aud 검증을 끈다
다른 resource용 token이 내 API에서 받아들여질 수 있습니다.
issuer 문자열을 느슨하게 비교한다
trust boundary가 불명확해질 수 있습니다.
token이 제공한 jku를 자동으로 따라간다
token이 자기 key source를 정하게 만들 수 있습니다.
무조건 새 token을 발급받는다
일시적으로 해결돼 보이지만 root cause가 rotation/cache/환경 mismatch라면 다시 발생합니다.
제가 실제로 기록하는 최소 진단 정보
production log에 전체 bearer token을 남기지는 않습니다. 대신 필요하면 다음 정도만 기록합니다.
- request correlation ID
- verifier error category
issaudkidalg- expiration 상태
- JWKS refresh 여부
민감한 token 본문을 로그에 남기지 않아도 대부분의 원인을 좁힐 수 있습니다.
최종 체크리스트
JWT 401이 발생하면 저는 다음 순서로 끝까지 확인합니다.
- Authorization header가 실제 요청에 존재하는가?
- 올바른 access token인가?
- JWT 구조가 정상인가?
exp,nbf, clock이 정상인가?- 허용 algorithm인가?
- trusted key/JWKS로 signature가 검증되는가?
iss가 exact match인가?aud에 현재 API가 포함되는가?- 필요한 scope/role이 있는가?
- rotation/cache 때문에 새
kid가 보이지 않는가?
이 순서를 지키면 “JWT가 보이는데 왜 401이지?”라는 막연한 문제를 전송 → 구조 → 시간 → cryptographic trust → issuer/audience → authorization 단계로 나눠서 해결할 수 있습니다.