- Published on
EKS Pod에서 OAuth2 400 invalid_grant 해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버리스나 VM에서는 잘 되던 OAuth2 토큰 발급이 EKS Pod로 옮긴 뒤 400 invalid_grant로 터지는 경우가 의외로 많습니다. 특히 Authorization Code 교환, Refresh Token 재발급, Client Credentials 흐름에서 동일한 에러 코드가 나오지만, 실제 원인은 네트워크/시간 동기화/헤더/리다이렉트 URI/시크릿 인코딩 등으로 다양합니다.
이 글은 “Pod 안에서만 invalid_grant가 난다”는 상황을 전제로, 원인을 빠르게 분류하고 재현 가능한 형태로 관측 지점을 늘린 뒤 안전하게 해결하는 방법을 다룹니다.
invalid_grant의 의미부터 정확히 잡기
OAuth2 스펙과 대부분의 IdP(예: Keycloak, Cognito, Auth0, Okta)는 invalid_grant를 대략 아래 범주로 씁니다.
- Authorization Code가 유효하지 않음: 이미 사용됨(재사용), 만료, 잘못된 client에 의해 교환
- Redirect URI 불일치: 승인 단계와 토큰 교환 단계의
redirect_uri가 다름(문자 하나라도) - Refresh Token이 무효: 회전(rotate) 정책으로 폐기됨, 만료, 잘못된 client, scope 불일치
- 리소스 오너 인증 실패(ROPC): username/password 틀림(가능한 IdP에서)
- 시간 기반 검증 실패: JWT
nbf/exp검증에서 클럭 스큐(clock skew)
중요한 점은, 애플리케이션 로그에 invalid_grant만 찍히면 “토큰 서버가 왜 거절했는지”가 숨겨집니다. 따라서 Pod 내부에서 실제로 어떤 요청이 나가는지를 먼저 확보해야 합니다.
1) Pod 내부에서 토큰 요청을 그대로 재현하기
가장 먼저 할 일은 Pod 안에서 curl로 토큰 엔드포인트를 직접 호출해, 애플리케이션/SDK 레이어를 걷어내는 것입니다.
디버그용 ephemeral container로 curl 실행
운영 Pod에 셸이 없다면 ephemeral container를 붙이는 게 가장 깔끔합니다.
kubectl -n <ns> debug -it pod/<pod-name> --image=curlimages/curl --target=<container-name> -- sh
Client Credentials 예시
TOKEN_URL="https://idp.example.com/oauth2/token"
CLIENT_ID="..."
CLIENT_SECRET="..."
curl -v -X POST "$TOKEN_URL" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "client_secret=$CLIENT_SECRET" \
--data-urlencode 'scope=read write'
여기서 확인할 것:
HTTP/1.1 400과 함께 내려오는 response body의 error_description- 실제로 어떤
Content-Type으로 전송되는지 - 프록시/사이드카가 있다면
Via,X-Forwarded-*헤더가 붙는지
> 애플리케이션 로그가 비어 있거나, 요청이 IdP까지 도달하지 않는 의심이 들면 egress 경로부터 점검하세요: EKS에서 Pod는 정상인데 egress만 막힐 때 점검
2) EKS에서 특히 자주 터지는 원인 TOP 7
아래는 “EKS Pod에서만” invalid_grant가 나오는 전형적인 패턴입니다.
(1) Redirect URI가 LB/Ingress 환경에서 바뀌었다
Authorization Code Flow에서 승인 요청의 redirect_uri와 토큰 교환 요청의 redirect_uri는 완전히 동일해야 합니다.
EKS에서는 다음 변화가 흔합니다.
- Ingress/ALB 뒤로 들어오며 외부 URL이
https://app.example.com/callback로 보이는데, - 앱 내부에서는
http://<pod-ip>:8080/callback혹은http://service:8080/callback로 계산해 토큰 교환을 시도
이때 IdP는 “승인 당시 redirect_uri와 다르다”고 보고 invalid_grant를 반환합니다.
해결 포인트
- 앱의 external URL / public URL 설정을 명시(예:
SERVER_PUBLIC_URL,X-Forwarded-*신뢰 설정) - Ingress에서
X-Forwarded-Proto,X-Forwarded-Host를 올바르게 전달 - IdP 클라이언트 설정에 redirect URI를 정확히 등록
Ingress에서 401/헤더 관련 이슈와 함께 나타나는 경우도 많습니다: EKS ALB Ingress 401 반복 - OIDC·JWT·헤더 점검
(2) Authorization Code를 두 번 교환(레이스 컨디션)
Pod가 여러 개일 때, callback 처리 과정에서 세션/상태(state)가 공유되지 않거나(특히 InMemory 세션), 재시도 로직이 겹치면 같은 code를 두 번 token endpoint로 보내는 일이 생깁니다.
- 첫 번째 교환: 성공
- 두 번째 교환: IdP에서 “이미 사용된 code”로 판단 →
invalid_grant
해결 포인트
- callback 처리의 멱등성(idempotency) 확보
- 세션 스토어를 Redis 등 외부 저장소로 이동
- ALB stickiness(세션 고정) 사용 여부 검토(근본 해결은 아님)
(3) Pod 시간 동기화/클럭 스큐로 인한 nbf/exp 검증 실패
EKS 노드는 일반적으로 NTP가 맞지만, 다음 상황에서 스큐가 체감됩니다.
- 컨테이너에서 토큰을 검증할 때 허용 스큐가 0초에 가깝게 설정됨
- IdP가 짧은 수명의 토큰/코드를 발급
- 노드 교체/부팅 직후 시간 동기화가 늦게 잡힘
진단
Pod 안에서 시간을 찍고(IdP 서버 시간과 비교), JWT의 nbf/exp를 확인합니다.
date -u
# access_token이 JWT라면 payload를 확인(서명 검증은 별도)
python - <<'PY'
import base64, json, os
jwt = os.environ.get('JWT','')
parts = jwt.split('.')
if len(parts) >= 2:
payload = parts[1] + '=='
data = base64.urlsafe_b64decode(payload)
print(json.dumps(json.loads(data), indent=2))
PY
해결 포인트
- JWT 검증 라이브러리의 clock skew 허용치 설정(예: 60~120초)
- 노드의 시간 동기화 상태 점검(가능하면 node-level에서)
(4) Client Secret/인증 헤더 인코딩 문제(개행, 공백, base64)
Kubernetes Secret을 주입하는 과정에서 다음이 섞이면, IdP는 client 인증 실패를 invalid_grant로 돌려주기도 합니다.
- base64 디코딩 후 개행(\n) 이 포함
- Helm values에서 따옴표/이스케이프가 잘못되어 공백이 섞임
Authorization: Basic base64(client_id:secret)생성 로직이 잘못됨
진단: Secret 값에 개행이 있는지 확인
kubectl -n <ns> get secret <secret-name> -o jsonpath='{.data.CLIENT_SECRET}' | base64 -d | cat -A
$나 ^M 같은 표시가 보이면 개행/CRLF가 끼어든 것입니다.
해결: stringData로 관리하거나, 값 정제
apiVersion: v1
kind: Secret
metadata:
name: oauth-client
type: Opaque
stringData:
CLIENT_ID: "my-client"
CLIENT_SECRET: "my-secret-without-newline"
(5) 토큰 엔드포인트로 나가는 egress가 프록시/방화벽에 의해 변형
네트워크가 막히면 보통 timeout/5xx가 나지만, 중간 프록시가 요청을 변형하거나 TLS 인터셉트가 걸리면 400 계열로 떨어질 수 있습니다.
체크
- Pod에
HTTPS_PROXY/HTTP_PROXY/NO_PROXY가 주입되어 있는지 - 사내 프록시가
application/x-www-form-urlencoded를 제대로 통과시키는지 - TLS가 중간에서 깨지지 않는지(
curl -v로 인증서 체인 확인)
egress 경로 점검은 위 내부 링크 글의 체크리스트가 빠릅니다: EKS에서 Pod는 정상인데 egress만 막힐 때 점검
(6) Refresh Token 회전 정책 + 다중 Pod 동시 갱신
Refresh Token Rotation이 켜져 있으면, 한 번 refresh하면 이전 refresh token은 폐기됩니다.
- Pod A가 refresh 수행 → refresh token R1 폐기, R2 발급
- Pod B가 거의 동시에 R1로 refresh 시도 →
invalid_grant
해결 포인트
- refresh를 “각 Pod가 제각각” 하지 않도록 중앙화(예: 토큰 캐시/락)
- refresh token을 사용자 세션 단위로 단일 writer가 되게 설계
- 가능하면 access token 만료 전 선제 갱신 시점을 분산(jitter)
(7) 잘못된 grant_type/파라미터 전송(특히 SDK 설정 차이)
환경변수로 grant 설정을 바꾸는 앱에서, EKS 배포 시 ConfigMap/Secret 조합이 달라져 grant_type이 바뀌는 경우가 있습니다.
예:
- 로컬:
client_credentials - EKS:
authorization_code로 설정되었는데 code가 없음 → invalid_grant
진단
애플리케이션이 실제로 전송하는 form body를 로깅하거나, SDK의 HTTP wire log를 켭니다.
3) 애플리케이션 레벨에서 “관측 가능성” 올리기
invalid_grant는 원인이 다양하므로, 다음 3가지만 추가해도 해결 시간이 크게 줄어듭니다.
- 토큰 요청 실패 시 response body 전체 로깅(민감정보 마스킹 필수)
- 요청에 포함된
grant_type,redirect_uri(해당 시),client_id(가능하면) 로깅 state/세션 키/사용자 식별자 등 “어떤 흐름의 요청인지” 상관관계 ID 부여
Node.js(axios) 예시: 에러 응답 바디 로깅
import axios from "axios";
async function fetchToken() {
try {
const params = new URLSearchParams({
grant_type: "client_credentials",
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
scope: "read write",
});
const res = await axios.post(process.env.TOKEN_URL, params.toString(), {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
timeout: 5000,
validateStatus: () => true,
});
if (res.status !== 200) {
// client_secret 등 민감정보는 절대 그대로 찍지 말 것
console.error("token_error", {
status: res.status,
data: res.data,
grant_type: "client_credentials",
});
throw new Error(`token request failed: ${res.status}`);
}
return res.data;
} catch (e) {
console.error("token_exception", { message: e.message });
throw e;
}
}
4) Kubernetes/EKS에서 자주 놓치는 배포 설정 체크리스트
여기서는 “OAuth2 자체”가 아니라, EKS 배포에서 실수하기 쉬운 지점을 정리합니다.
환경변수 우선순위 꼬임(Secret/ConfigMap)
동일 키가 여러 소스에서 주입되면, 의도치 않게 다른 값이 들어갑니다.
envFrom:
- configMapRef:
name: app-config
- secretRef:
name: app-secret
# 같은 키가 있으면 뒤/앞 우선순위에 따라 덮어써짐(운영 표준에 맞춰 정리 필요)
readinessProbe 실패로 인한 재시도 폭발 → code 재사용
로그인 callback 처리 중 readiness가 흔들리면, Ingress/LB가 재시도하며 동일 요청이 반복될 수 있습니다. 그 결과 code가 재사용되어 invalid_grant로 보일 수 있습니다.
- readiness 조건을 “외부 IdP 토큰 발급 성공” 같은 무거운 조건으로 잡지 말기
- callback 요청을 처리하는 동안 Pod 재시작이 일어나지 않게 리소스/프로브 조정
Pod가 재시작/루프를 타는 상황이라면 먼저 안정화가 필요합니다: Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅
5) 문제를 빠르게 좁히는 “10분 진단 순서”
현장에서 가장 효율적인 순서는 보통 아래입니다.
- Pod 내부에서 curl로 토큰 요청 재현 (애플리케이션 제거)
- response의
error_description확보 - Authorization Code Flow라면:
- 승인 요청과 토큰 요청의
redirect_uri문자열이 완전히 동일한지 비교 - 동일 code가 두 번 교환되는지(로그/트레이싱)
- 승인 요청과 토큰 요청의
- Refresh Token이라면:
- rotation 정책 여부 확인
- 다중 Pod 동시 refresh 여부 확인
- Client Secret 문제:
- Secret 개행/공백/인코딩 확인
- 시간 문제:
- Pod/Node UTC 시간 확인 + JWT
nbf/exp와 비교
- Pod/Node UTC 시간 확인 + JWT
- 네트워크/프록시:
HTTPS_PROXY/TLS 인터셉트/egress 경로 확인
6) 결론: invalid_grant는 “인증 실패”가 아니라 “불일치”의 신호
EKS Pod에서 400 invalid_grant가 발생하면, 단순히 “자격 증명이 틀렸다”보다 **흐름의 불일치(redirect_uri/state/code 재사용/refresh 경쟁/시간 스큐/시크릿 포맷)**일 가능성이 큽니다.
가장 중요한 실전 팁은 두 가지입니다.
- Pod 내부에서 curl로 동일 요청을 재현해 관측 지점을 확보한다.
- Authorization Code/Refresh Token 흐름에서는 다중 Pod 환경에서의 경쟁 조건을 먼저 의심한다.
위 체크리스트대로 한 단계씩 좁혀가면, 대부분의 invalid_grant는 “정확히 무엇이 불일치했는지”가 드러나고, 그 지점만 고치면 재발도 막을 수 있습니다.