- Published on
OAuth PKCE invalid_grant 검증 실패 8가지 원인
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 로그인(구글/애플/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 방식의 계산은 다음과 같습니다.
SHA256(code_verifier)- 결과를 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_verifier는 Authorization 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_uri는 authorization code를 발급받을 때 사용한 redirect_uri와 완전히 동일해야 합니다(IdP 다수 구현에서 엄격 비교).
자주 틀리는 포인트:
https://app/callbackvshttps://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분 내 좁혀집니다.
- 토큰 요청이 중복으로 나가나? (code 재사용)
- redirect_uri가 authorize와 완전 동일한가?
- state별로 code_verifier를 정확히 복원했나?
- S256 계산이 Base64URL/패딩 제거까지 정확한가?
- code_challenge_method가 실제 계산과 일치하나?
- authorize와 token의 client_id/issuer가 동일한가?
- code 만료/시간 오차 이슈가 있나?
- verifier 길이/문자 제약을 지켰나?
운영에서의 관측 포인트(로그/추적)
invalid_grant는 보안상 상세 사유를 숨기는 경우가 많습니다. 따라서 애플리케이션에서 다음을 “민감정보 제외” 형태로 로깅하면 해결 속도가 크게 올라갑니다.
state(원문 그대로 저장 가능)redirect_uri(원문)code_challenge_methodcode_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의 대부분은 해결됩니다.