Published on

OAuth PKCE invalid_grant 검증 실패 8가지 원인

Authors

서드파티 로그인(구글/애플/Okta/Auth0/Keycloak 등)을 붙일 때 가장 흔히 마주치는 에러 중 하나가 토큰 엔드포인트의 invalid_grant입니다. 특히 Authorization Code + PKCE 플로우에서는 “코드는 맞는데 왜 invalid_grant지?”라는 상황이 자주 발생합니다. 이유는 대부분 코드(code) 자체가 잘못된 게 아니라, PKCE 검증(= code_verifier ↔ code_challenge 매칭) 또는 **코드의 사용 조건(redirect_uri, client, 만료/재사용)**이 어긋났기 때문입니다.

이 글에서는 PKCE 환경에서 invalid_grant가 발생하는 8가지 대표 원인을 “증상 → 원인 → 확인 방법 → 해결” 순서로 정리합니다. (IdP마다 메시지 문구는 다르지만, 본질은 거의 동일합니다.)

> 참고: 네트워크/인프라 레벨에서 TLS나 타임아웃이 섞이면 원인 파악이 더 어려워집니다. EKS에서 토큰 교환 호출이 간헐 실패한다면 EKS TLS handshake timeout 원인·해결 9가지도 함께 점검하세요.

PKCE 검증이 실제로 무엇을 검증하는가

PKCE는 간단히 말해 다음을 보장합니다.

  • Authorization Request에서 보낸 code_challenge
  • Token Request에서 보낸 code_verifier로부터
  • **동일한 방식(S256 또는 plain)**으로 계산된 값인지 확인

S256 방식의 계산은 다음과 같습니다.

  1. SHA256(code_verifier)
  2. 결과를 Base64URL 인코딩(패딩 = 제거, +-, /_)

이 둘이 한 글자라도 다르면 IdP는 보통 invalid_grant로 응답합니다.

재현/디버깅을 위한 최소 cURL 템플릿

문제의 80%는 “내가 실제로 무엇을 보냈는지”를 정확히 보면 해결됩니다. 토큰 요청을 반드시 원문 그대로 캡처해 비교하세요.

curl -sS -X POST "https://idp.example.com/oauth2/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "client_id=${CLIENT_ID}" \
  -d "code=${AUTH_CODE}" \
  -d "redirect_uri=${REDIRECT_URI}" \
  -d "code_verifier=${CODE_VERIFIER}" \
  | jq

> 일부 IdP는 confidential client에서 client_secret 또는 Authorization: Basic ...를 요구합니다. 하지만 PKCE 기반 SPA/모바일은 보통 public client로 구성합니다.

원인 1) code_verifier를 잘못 저장/복원(세션, 쿠키, 스토리지)

증상

  • 로그인 페이지에서 인증 성공 후 콜백까지는 정상
  • 토큰 교환에서만 invalid_grant
  • 새로고침/탭 이동/앱 백그라운드 복귀 후 특히 자주 발생

원인

code_verifierAuthorization Request를 만들 때 생성하고, Token Request 때 동일한 값을 보내야 합니다. 그런데 아래 실수로 값이 바뀝니다.

  • 콜백 처리 전에 페이지 리로드 → 메모리 변수 초기화
  • state별로 verifier를 저장하지 않고 단일 키로 덮어씀
  • 멀티탭 로그인에서 verifier 충돌
  • 쿠키 SameSite/도메인 문제로 저장 실패

확인 방법

  • 콜백 직전/직후에 code_verifier를 로그로 남기고 동일한지 확인
  • state 값과 함께 매핑 저장했는지 확인

해결

  • state를 키로 code_verifier를 저장
  • SPA라면 sessionStorage + state 매핑 권장(탭 격리)
// 생성 시
const state = crypto.randomUUID();
sessionStorage.setItem(`pkce:${state}`, verifier);

// 콜백 시
const verifier = sessionStorage.getItem(`pkce:${stateFromCallback}`);
if (!verifier) throw new Error("Missing PKCE verifier");

원인 2) code_challenge 계산(Base64URL) 구현 오류

증상

  • 항상 실패하거나, 특정 길이/문자 조합에서만 실패
  • IdP 로그에 “PKCE verification failed” 류 문구

원인

S256 계산에서 흔한 실수는 다음입니다.

  • Base64가 아닌 Base64URL로 인코딩해야 하는데 변환 누락
  • 패딩 = 제거 누락
  • UTF-8 인코딩 처리 실수(특히 모바일/네이티브)

확인 방법

  • 같은 code_verifier로 로컬에서 code_challenge를 다시 계산해 IdP로 보낸 값과 비교

올바른 계산 예시(Node.js)

import crypto from "crypto";

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

export function pkceChallengeS256(verifier) {
  const hash = crypto.createHash("sha256").update(verifier, "utf8").digest();
  return base64url(hash);
}

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

증상

  • 어떤 환경에서는 성공, 어떤 환경에서는 실패
  • IdP 설정 변경 후 갑자기 실패

원인

Authorization Request에서 code_challenge_method=S256로 보냈는데, 실제 challenge는 plain처럼(=verifier 그대로) 보내거나 반대의 경우입니다.

또는 IdP가 S256만 허용(권장)인데 클라이언트가 plain을 보내면 거절됩니다.

확인 방법

  • 인증 요청 URL에 code_challenge_method가 무엇인지 확인
  • IdP 콘솔에서 PKCE 정책(S256 강제 여부) 확인

해결

  • 기본을 S256로 고정
  • 라이브러리 혼용(프론트 A, 모바일 B) 시 동일 정책 적용

원인 4) redirect_uri 불일치(한 글자 차이도 실패)

증상

  • “PKCE”라고 생각했지만 사실 redirect_uri mismatch로 invalid_grant
  • 로컬/스테이징/프로덕션 중 특정 환경만 실패

원인

OAuth 토큰 요청의 redirect_uriauthorization code를 발급받을 때 사용한 redirect_uri와 완전히 동일해야 합니다(IdP 다수 구현에서 엄격 비교).

자주 틀리는 포인트:

  • https://app/callback vs https://app/callback/(슬래시)
  • 쿼리 파라미터 유무
  • 대소문자
  • 포트(127.0.0.1:3000 vs localhost:3000)

확인 방법

  • Authorization Request에 사용한 redirect_uri를 그대로 로그로 남기기
  • Token Request에 넣은 redirect_uri와 diff 비교

해결

  • redirect_uri를 코드 상수화(두 곳에서 조립하지 말 것)
  • 환경별 설정 파일로 단일 소스 유지

원인 5) Authorization Code 재사용(중복 토큰 교환)

증상

  • 첫 시도는 성공, 이후 동일 code로 재시도하면 invalid_grant
  • 프론트에서 콜백 핸들러가 두 번 실행되는 경우(React StrictMode 등)

원인

Authorization Code는 1회용입니다. 동일 code로 토큰 엔드포인트를 두 번 호출하면 두 번째는 보통 invalid_grant입니다.

이중 호출이 발생하는 대표 케이스:

  • SPA 라우팅에서 콜백 컴포넌트가 마운트 2회
  • 네트워크 재시도 로직(axios retry)로 POST를 재전송
  • 백엔드와 프론트가 동시에 토큰 교환(구조 혼선)

확인 방법

  • 토큰 엔드포인트 호출에 요청 ID를 붙여 서버 로그에서 중복 여부 확인

해결

  • 콜백 처리에 idempotency 가드 추가
let exchanging = false;
async function exchangeOnce() {
  if (exchanging) return;
  exchanging = true;
  try {
    await exchangeToken();
  } finally {
    exchanging = false;
  }
}

원인 6) code 만료(짧은 TTL) + 시간 지연/클럭 스큐

증상

  • 사용자가 로그인 화면에서 오래 머무르면 실패
  • 모바일에서 앱 전환 후 돌아오면 실패
  • 서버 시간이 틀어진 환경에서만 실패

원인

Authorization Code의 TTL은 짧습니다(수십 초~수분). 다음이 겹치면 만료로 invalid_grant가 납니다.

  • 사용자 상호작용 지연
  • 네트워크 지연/재시도
  • 서버/컨테이너 시간 오차(NTP 미동기화)

확인 방법

  • IdP 로그에서 “code expired” 류 메시지 확인
  • 서버 시간과 표준시간 오차 확인

해결

  • 코드 교환을 콜백 즉시 수행
  • 인프라에서 NTP 동기화 보장

원인 7) 다른 클라이언트/테넌트로 토큰 교환(클라이언트 불일치)

증상

  • 환경 변수 바꾼 뒤부터 invalid_grant
  • 프론트는 A 클라이언트로 authorize, 백엔드는 B 클라이언트로 token

원인

Authorization Code는 발급된 client_id(및 테넌트/issuer)에 귀속됩니다. authorize와 token 요청의 클라이언트가 다르면 실패합니다.

특히 다음 구조에서 자주 발생:

  • 프론트에서 authorize 수행(공개 클라이언트)
  • 백엔드에서 token 교환 수행(비공개 클라이언트)
  • 둘의 client_id가 다름

확인 방법

  • authorize 요청에 사용한 client_id, issuer(도메인) 기록
  • token 요청의 client_id/secret, issuer 비교

해결

  • “누가 토큰 교환을 할 것인가”를 먼저 결정
    • SPA/모바일이면 보통 클라이언트가 직접 token 교환
    • 백엔드가 교환할 거면 처음부터 백엔드 중심(BFF) 플로우로 설계

원인 8) code_verifier 형식/길이 제약 위반(스펙 미준수)

증상

  • 특정 라이브러리/플랫폼에서만 실패
  • IdP에 따라 “invalid_grant”로 뭉뚱그려 반환

원인

RFC 7636에서 code_verifier는 다음을 만족해야 합니다.

  • 길이: 43~128
  • 문자: unreserved characters (A-Z a-z 0-9 - . _ ~)

실수 포인트:

  • 너무 짧은 랜덤 문자열
  • Base64(일반) 문자열을 그대로 써서 +, /, = 포함
  • URL 인코딩/디코딩 과정에서 값이 변형(%2B 등)

확인 방법

  • verifier 길이와 문자셋을 검증

해결

  • 안전한 문자셋으로 생성(권장: Base64URL 또는 unreserved만)
import os, base64, re

def gen_verifier(nbytes=64):
    v = base64.urlsafe_b64encode(os.urandom(nbytes)).decode().rstrip('=')
    assert 43 <= len(v) <= 128
    assert re.fullmatch(r"[A-Za-z0-9\-\._~]+", v)
    return v

빠른 체크리스트(현장용)

아래 순서대로 보면 대개 10분 내 좁혀집니다.

  1. 토큰 요청이 중복으로 나가나? (code 재사용)
  2. redirect_uri가 authorize와 완전 동일한가?
  3. state별로 code_verifier를 정확히 복원했나?
  4. S256 계산이 Base64URL/패딩 제거까지 정확한가?
  5. code_challenge_method가 실제 계산과 일치하나?
  6. authorize와 token의 client_id/issuer가 동일한가?
  7. code 만료/시간 오차 이슈가 있나?
  8. verifier 길이/문자 제약을 지켰나?

운영에서의 관측 포인트(로그/추적)

invalid_grant는 보안상 상세 사유를 숨기는 경우가 많습니다. 따라서 애플리케이션에서 다음을 “민감정보 제외” 형태로 로깅하면 해결 속도가 크게 올라갑니다.

  • state (원문 그대로 저장 가능)
  • redirect_uri (원문)
  • code_challenge_method
  • code_verifier는 원문 대신 길이 + 해시(SHA-256)
  • 토큰 요청의 시각, 요청 ID, 재시도 여부
import crypto from "crypto";

function sha256hex(s: string) {
  return crypto.createHash("sha256").update(s, "utf8").digest("hex");
}

logger.info({
  state,
  redirectUri,
  method: codeChallengeMethod,
  verifierLen: verifier.length,
  verifierHash: sha256hex(verifier),
  requestId,
}, "PKCE token exchange attempt");

> 네트워크 레이어에서 5xx/타임아웃이 섞이면 “재시도 → 코드 재사용 → invalid_grant”로 번질 수 있습니다. 인프라 지연이 의심되면 Cloud Run 504 Timeout 원인·해결 9가지처럼 타임아웃/재시도 정책도 같이 점검하는 것이 좋습니다.

마무리

PKCE에서 invalid_grant는 대부분 “PKCE 자체가 틀렸다”기보다, (1) verifier 보존 실패, (2) challenge 계산/메서드 불일치, (3) redirect_uri/클라이언트 불일치, (4) 코드 만료/재사용 중 하나로 귀결됩니다.

문제가 재현이 어렵다면, 먼저 토큰 요청이 단 한 번만 나가는지를 확인하고(원인 5), 다음으로 authorize와 token의 입력값을 1:1로 비교하세요(원인 1~4, 7). 이 두 단계만으로도 PKCE invalid_grant의 대부분은 해결됩니다.