Published on

OAuth PKCE invalid_grant 실패 8가지 원인

Authors

서론

모바일/SPA에서 Authorization Code Flow를 쓸 때 PKCE(Proof Key for Code Exchange)는 사실상 표준입니다. 그런데 운영에서 가장 자주 마주치는 실패가 token endpoint에서의 invalid_grant입니다. 문제는 invalid_grant너무 포괄적이라서, 같은 에러 문자열로도 원인이 완전히 다를 수 있다는 점입니다.

이 글은 “PKCE를 적용했는데 token 교환에서 invalid_grant가 뜬다”를 기준으로, 현장에서 반복적으로 나오는 원인 8가지를 증상 → 원인 → 확인 방법 → 해결 순으로 정리합니다. (Keycloak, Auth0, Cognito 등 벤더마다 메시지/로그는 다르지만, 실패 메커니즘은 대부분 동일합니다.)

관련해서 리다이렉트/세션 꼬임으로 인증이 무한 루프를 도는 케이스도 자주 동반되니, 상황이 비슷하다면 Keycloak OAuth 로그인 무한 302 리다이렉트 해결도 함께 확인해보면 진단 속도가 빨라집니다.


PKCE invalid_grant를 빠르게 분류하는 관찰 포인트

invalid_grant는 대개 아래 3단계 중 하나에서 발생합니다.

  1. authorization code 자체가 유효하지 않음 (만료/재사용/오타/다른 클라이언트)
  2. code는 맞는데 “교환 조건”이 불일치 (redirect_uri, code_verifier, client_id 등)
  3. 서버가 코드 검증에 필요한 상태를 잃음 (클러스터/세션/스토리지/시간)

따라서 먼저 다음을 확보하세요.

  • token endpoint 요청 전문(민감정보 마스킹)
  • authorization endpoint에서 받은 code 원문(길이/인코딩 포함)
  • 서버 로그에서 “code consumed”, “PKCE verification failed”, “redirect mismatch” 같은 키워드
  • 발급/교환 시각(초 단위)과 서버 시간(NTP)

아래 예시는 가장 흔한 token 교환 요청 형태입니다.

curl -X POST "https://idp.example.com/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "client_id=my-spa" \
  -d "code=SplxlOBeZQQYbYS6WxSbIA" \
  -d "redirect_uri=https://app.example.com/callback" \
  -d "code_verifier=Z2V0LWl0LXJpZ2h0LXBrc2UtdmVyaWZpZXI"

원인 1) authorization code 만료(교환 지연)

증상

  • 사용자가 로그인은 성공했는데, callback 이후 token 교환이 간헐적으로 실패
  • 서버/클라이언트 로그에 “code expired” 류 메시지(없을 수도 있음)

원인

Authorization Code는 보통 수십 초~수 분의 매우 짧은 TTL을 가집니다. 다음이 겹치면 만료가 빨라집니다.

  • 모바일에서 앱 전환/백그라운드로 callback 처리 지연
  • SPA에서 callback 라우트 로딩 지연(번들 다운로드, 느린 네트워크)
  • 서버에서 token 교환을 큐잉/재시도하다가 늦어짐

확인 방법

  • code 발급 시각과 token 교환 시각 차이를 초 단위로 기록
  • IdP 설정에서 Authorization Code lifetime 확인

해결

  • callback 처리 경로를 가볍게(불필요한 API 호출/렌더링 제거)
  • code 교환을 즉시 수행하고, 이후 사용자 정보 로딩
  • IdP에서 code TTL을 늘릴 수 있으면 조정(보안 정책과 트레이드오프)

원인 2) authorization code 재사용(중복 교환)

증상

  • 첫 번째 교환은 성공, 동일 code로 두 번째 교환부터 invalid_grant
  • 프론트/백엔드 모두 token 교환을 시도하는 구조에서 자주 발생

원인

Authorization Code는 1회성(one-time) 입니다. 다음 패턴이 흔합니다.

  • SPA가 token 교환을 하고, 동시에 백엔드도 같은 code로 교환
  • callback 라우트가 두 번 실행(React StrictMode 개발 환경, 이중 네비게이션)
  • 네트워크 재시도로 같은 요청이 중복 전송

확인 방법

  • token endpoint 호출이 같은 code로 2번 이상 찍히는지 확인
  • 프론트 라우터/미들웨어에서 callback 처리 중복 여부 확인

해결

  • token 교환 주체를 단일화(권장: 백엔드 BFF 또는 프론트 단독)
  • callback 처리에 idempotency 가드 추가

예: SPA에서 callback 처리 1회만 수행

// callback.ts
const key = `pkce_exchange_done_${new URLSearchParams(location.search).get('code')}`;
if (sessionStorage.getItem(key)) {
  // 이미 교환 시도했으면 중단
  throw new Error('Duplicate code exchange prevented');
}
sessionStorage.setItem(key, '1');

원인 3) redirect_uri 불일치(완전 일치 요구)

증상

  • 로그인 페이지에서 정상 인증 후, token 교환에서만 실패
  • IdP 로그에 “redirect_uri mismatch”가 찍히거나, 그냥 invalid_grant만 반환

원인

많은 IdP는 token 요청의 redirect_uri가 authorization 요청 때의 redirect_uri문자열 완전 일치해야 합니다.

다음 차이도 “불일치”입니다.

  • trailing slash(/callback vs /callback/)
  • 쿼리스트링 포함 여부
  • URL 인코딩 차이
  • http/https, 포트(443 명시 여부)
  • 프록시/로드밸런서 뒤에서 외부 URL과 내부 URL 혼용

확인 방법

  • authorization 요청과 token 요청의 redirect_uri를 그대로 로그로 남겨 비교
  • 프록시 환경이면 X-Forwarded-Proto, X-Forwarded-Host 처리 확인

해결

  • authorization/token 단계에서 같은 redirect_uri를 상수로 공유
  • 서버에서 외부 base URL을 명시적으로 설정(프록시 신뢰 설정 포함)

원인 4) code_verifier가 원본과 다름(저장/인코딩/길이 문제)

증상

  • 특정 브라우저/앱에서만 실패
  • 개발 환경에서는 되는데 운영에서만 실패(스토리지/도메인/리다이렉트 차이)

원인

PKCE는 code_challenge = BASE64URL(SHA256(code_verifier))(S256) 관계를 검증합니다. 즉 token 요청의 code_verifierauthorization 요청 때 challenge를 만들었던 그 문자열 그대로여야 합니다.

흔한 깨짐 원인:

  • code_verifier를 localStorage/sessionStorage에 저장했는데, 리다이렉트/도메인 변경으로 읽지 못함
  • 문자열이 URL-safe가 아닌 방식으로 인코딩/디코딩됨(+, /, = 처리)
  • verifier 길이 제한 위반(일반적으로 43~128 chars 권장)
  • 모바일 deep link에서 verifier 전달 과정에서 공백/개행이 섞임

확인 방법

  • verifier를 생성한 직후와 token 요청 직전에 해시를 찍어 비교(민감정보 주의)
  • 브라우저 스토리지 접근 가능 여부(사파리 ITP, 서드파티 컨텍스트) 확인

해결

  • verifier는 “문자열 그대로” 보존. base64url로 만든 후 추가 인코딩하지 않기
  • redirect를 거쳐도 유지되는 저장소 전략 선택(동일 오리진 유지, BFF 사용 등)

예: Node/TS에서 올바른 PKCE 생성(S256)

import crypto from 'crypto';

function base64url(buf: Buffer) {
  return buf.toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/g, '');
}

export function generatePkce() {
  const verifier = base64url(crypto.randomBytes(32)); // 보통 43자 이상 확보
  const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
  return { verifier, challenge, method: 'S256' as const };
}

원인 5) code_challenge_method 불일치(S256 vs plain)

증상

  • 어떤 IdP/테넌트에서는 되고, 다른 환경에서는 invalid_grant
  • 서버 로그에 PKCE method 관련 경고가 나타나기도 함

원인

Authorization 요청에서 code_challenge_method=S256로 보냈는데 실제로는 plain으로 계산했거나(혹은 반대), IdP가 plain을 허용하지 않는데 plain으로 보낸 경우입니다.

또한 라이브러리가 기본값으로 plain을 쓰는 경우가 있습니다.

확인 방법

  • authorization 요청 파라미터를 캡처해 code_challenge_method 확인
  • 클라이언트 라이브러리 설정(예: “usePkce: true”만으로 S256이 보장되는지)

해결

  • 가능하면 항상 S256 사용
  • IdP에서 PKCE 정책(plain 허용 여부) 확인

원인 6) client_id / 앱 타입 불일치(Confidential vs Public)

증상

  • 같은 사용자/같은 code인데 클라이언트에 따라 실패
  • 서버에서 “unauthorized client”가 아닌데도 invalid_grant로 뭉개서 반환

원인

PKCE는 주로 Public Client(SPA/모바일)에서 사용하지만, 실제 설정은 IdP마다 다릅니다.

  • token 요청에 client_secret을 보내야 하는 confidential client인데 secret 없이 요청
  • 반대로 public client인데 secret을 붙여서 다른 클라이언트로 인식
  • 잘못된 client_id로 교환 시도(환경 변수/테넌트 혼동)

확인 방법

  • IdP의 클라이언트 설정에서 “public/confidential”, “PKCE required” 확인
  • token 요청에 client 인증 방식(client_secret_basic vs post vs none) 확인

해결

  • SPA/모바일은 보통 public client + PKCE + secret 없음
  • 백엔드에서 교환할 거면 confidential client로 구성하고 인증 방식 일치

원인 7) 클러스터/세션 스토리지 문제로 코드 검증 상태 유실

증상

  • 단일 인스턴스에서는 재현 안 되는데, 다중 인스턴스/오토스케일에서만 간헐 실패
  • 특정 시간대(배포/스케일링) 이후 invalid_grant 급증

원인

IdP가 authorization code, PKCE challenge, 세션 정보를 메모리나 로컬 스토리지에만 보관하거나(혹은 캐시가 일관되지 않거나), 로드밸런서가 세션 고정(sticky)을 보장하지 않으면 다음이 발생합니다.

  • A 인스턴스에서 code 발급
  • token 교환은 B 인스턴스로 들어감
  • B는 해당 code/challenge 상태를 몰라서 invalid_grant

확인 방법

  • IdP가 클러스터링/분산 캐시(예: Infinispan, Redis)를 쓰는지
  • LB의 sticky session 설정 여부
  • 배포 직후/스케일 이벤트 직후에만 실패하는지

해결

  • IdP를 공식 가이드대로 클러스터링(분산 캐시/DB) 구성
  • 필요 시 token endpoint에 sticky session 적용(권장 여부는 제품별 상이)

인프라 측면에서 인증 서버도 결국 “분산 상태” 문제를 겪습니다. 이벤트 중복/순서가 시스템을 망가뜨리는 패턴은 도메인은 달라도 유사하니, 재처리/중복 관점이 익숙하지 않다면 DDD 이벤트 소싱 마이그레이션 - 중복·순서·재처리도 참고가 됩니다.


원인 8) 서버 시간(NTP) 불일치 또는 토큰/코드 검증 시각 문제

증상

  • 특정 노드에서만 지속적으로 실패
  • “expired”로 의심되지만 실제로는 즉시 교환했는데도 실패

원인

일부 IdP 구현은 code의 발급/만료를 서버 시간에 강하게 의존합니다. 클러스터 내 노드 간 시간이 어긋나면,

  • 발급 노드는 “아직 유효”
  • 검증 노드는 “이미 만료”

로 판단할 수 있습니다.

확인 방법

  • IdP 노드들의 시간 오차 확인(chronyc tracking, timedatectl)
  • 컨테이너/VM/호스트 레벨 NTP 동기화 상태 점검

해결

  • 모든 노드 NTP 강제 동기화
  • 쿠버네티스/EKS 환경이면 노드 그룹별 시간 동기 정책 확인

실전 디버깅 체크리스트(로그/패킷/재현)

운영에서 빠르게 결론을 내리려면 “재현”보다 “증거 수집”이 먼저입니다.

1) token 요청 전문을 재구성 가능하게 남기기

민감정보를 마스킹하되, 아래 필드는 반드시 남기세요.

  • grant_type, client_id, redirect_uri
  • code 길이/앞뒤 일부(예: 앞 6자, 뒤 6자)
  • code_verifier 길이(원문은 남기지 않는 것을 권장)

예: 서버 로그 마스킹 예시(Java)

static String mask(String s) {
  if (s == null) return null;
  if (s.length() <= 12) return "***";
  return s.substring(0, 6) + "..." + s.substring(s.length() - 6);
}

log.info("token exchange: clientId={}, redirectUri={}, code={}, verifierLen={}",
  clientId, redirectUri, mask(code), codeVerifier == null ? 0 : codeVerifier.length());

2) “authorization 요청”과 “token 요청”을 한 트레이스로 묶기

  • state(또는 별도 correlation id)를 로그 MDC에 넣어 end-to-end로 추적
  • 프론트/백엔드가 나뉘면 동일한 trace id를 전달

3) 재시도 전략을 무작정 넣지 않기

invalid_grant는 대부분 영구 실패(permanent) 입니다. 재시도는 오히려 code 재사용을 유발해 문제를 악화시킬 수 있습니다. 재시도가 필요하다면, 네트워크 오류/5xx 등 “일시 실패”만 선별하세요. 재시도/백오프 설계 감각은 OAuth뿐 아니라 전반적인 분산 시스템 안정화에 중요하니, 패턴 정리가 필요하면 Claude API 529 Overloaded 재시도·백오프 설계도 도움이 됩니다.


결론

PKCE에서의 invalid_grant는 한 문장으로 뭉개져 나오지만, 실제로는 크게 8가지 축(만료, 재사용, redirect 불일치, verifier 깨짐, method 불일치, client 설정 불일치, 클러스터 상태 유실, 시간 불일치)으로 수렴합니다.

가장 효율적인 접근은 “PKCE 수학”을 다시 공부하는 것이 아니라,

  • authorization 요청 파라미터
  • token 요청 파라미터

정확히 캡처해서 “완전 일치해야 하는 것”을 하나씩 대조하는 것입니다. 특히 redirect_uricode_verifier는 문자열 한 글자 차이로도 실패하므로, 생성/저장/전달 경로를 단순화하는 것만으로도 운영 장애의 80%는 사라집니다.

원한다면 사용 중인 IdP(Keycloak/Cognito/Auth0 등)와 클라이언트 타입(SPA/모바일/BFF), 그리고 token 요청/응답 샘플(마스킹)을 주면 위 8가지 중 어디에 해당하는지 더 구체적으로 좁혀서 체크리스트를 맞춤화해드릴 수 있습니다.