- Published on
OAuth PKCE인데 invalid_grant 뜨는 9가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 로그인(OAuth 2.0 Authorization Code + PKCE)을 붙이다 보면, 인증 화면까지는 잘 갔다가 **토큰 엔드포인트(/token)에서 invalid_grant**로 막히는 순간이 옵니다. PKCE를 썼으니 안전할 텐데 왜 invalid_grant냐고요?
핵심은 invalid_grant가 “PKCE 실패”만 뜻하는 게 아니라, authorization code의 유효성/소비 여부/클라이언트 매칭/redirect_uri 매칭/시간/재사용 등 토큰 발급에 필요한 전제 조건이 하나라도 어긋나면 뭉뚱그려 터지는 에러라는 점입니다.
이 글은 PKCE 기반 Authorization Code Flow에서 invalid_grant가 뜨는 원인을 9가지로 쪼개서 재현 포인트, 확인 방법, 해결책을 함께 제공합니다.
> 참고: 운영 트러블슈팅 글 스타일이 마음에 들면 Argo CD Sync Failed/OutOfSync 원인 10가지처럼 “증상 → 원인 분해 → 체크리스트” 방식으로 접근하면 디버깅 속도가 빨라집니다.
0) 먼저: PKCE 토큰 교환의 최소 요건
토큰 교환 요청은 보통 아래처럼 생깁니다.
curl -sS -X POST "https://auth.example.com/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "client_id=YOUR_CLIENT_ID" \
-d "code=AUTHORIZATION_CODE" \
-d "redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback" \
-d "code_verifier=YOUR_CODE_VERIFIER"
여기서 invalid_grant는 대개 아래 중 하나가 깨졌다는 뜻입니다.
code가 유효하지 않음(만료/이미 사용됨/다른 클라이언트의 코드)redirect_uri가 인가 요청 때와 100% 동일하지 않음code_verifier가 PKCE 규격에 맞지 않거나,code_challenge와 매칭 실패- 서버가 시간/nonce/state 등 추가 검증에서 실패
이제 실제로 가장 자주 터지는 9가지를 보겠습니다.
1) redirect_uri가 “완전히” 일치하지 않음
PKCE에서 가장 흔한 함정입니다. 인가 요청(/authorize) 때 보낸 redirect_uri와 토큰 요청(/token) 때 보낸 redirect_uri는 문자열이 완전히 동일해야 합니다.
흔한 불일치 패턴
- 트레일링 슬래시 차이:
https://app/callbackvshttps://app/callback/ - 스킴 차이:
httpvshttps - 포트 차이:
https://localhost:3000/callbackvshttps://localhost/callback - 쿼리스트링 포함 여부:
.../callback?foo=1vs.../callback - URL 인코딩 차이(특히 쿼리 포함 시)
해결
- 인가 요청에서 사용한
redirect_uri를 그대로 저장해 토큰 교환에 재사용 - 환경별로(로컬/스테이징/프로덕션) redirect URI를 명시적으로 분리
2) code_verifier가 인가 요청 때의 code_challenge와 매칭되지 않음
PKCE의 본질은:
- 인가 요청:
code_challenge = BASE64URL(SHA256(code_verifier))(S256) - 토큰 요청:
code_verifier를 제출 - 서버가
code_verifier로 다시code_challenge를 계산해 비교
이 매칭이 깨지면 invalid_grant가 납니다.
자주 하는 실수
code_verifier를 새로 생성해버림(인가 요청과 토큰 요청이 동일한 verifier를 공유해야 함)- 서버/클라이언트가 서로 다른 방식으로 base64url 처리
S256인데plain으로 보내거나 그 반대
Node.js 예시: 올바른 S256 code_challenge 생성
import crypto from "crypto";
function base64url(buffer) {
return buffer
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
export function createPkcePair() {
const codeVerifier = base64url(crypto.randomBytes(32));
const hash = crypto.createHash("sha256").update(codeVerifier).digest();
const codeChallenge = base64url(hash);
return { codeVerifier, codeChallenge, codeChallengeMethod: "S256" };
}
해결
code_verifier를 세션/스토리지/서버 세션에 저장하고 토큰 교환 시 그대로 사용code_challenge_method를 명시(S256권장)
3) code_verifier 포맷이 PKCE 규격을 위반
RFC 7636에 따르면 code_verifier는:
- 길이: 43~128
- 문자:
A-Z a-z 0-9 - . _ ~
여기서 벗어나면 일부 IdP는 invalid_grant로 뭉개서 반환합니다.
흔한 실수
- base64 표준을 그대로 써서
+,/,=가 포함됨 - 너무 짧은 verifier(예: UUID 그대로)
해결
- 반드시 base64url 형태로 만들고 padding 제거
- 길이 체크를 코드에 넣어 사전 차단
4) Authorization Code를 두 번 교환(재사용)함
Authorization Code는 1회용입니다. 프론트/백엔드/리트라이 로직에서 같은 코드를 두 번 /token에 던지면 두 번째부터 invalid_grant가 납니다.
흔한 재사용 시나리오
- 콜백 페이지가 두 번 로드됨(리다이렉트 루프, SPA 라우터 이중 실행)
- 네트워크 타임아웃으로 재시도했는데 첫 요청은 성공 처리됨
- 백엔드와 프론트가 동시에 토큰 교환을 시도
해결
- 콜백 처리에서
code를 읽자마자 원자적으로 소비 처리(예: 서버 세션에 “처리됨” 마킹) - 프론트에서는 콜백 라우트 진입 시 중복 실행 방지 플래그 적용
5) Authorization Code가 만료됨(짧은 TTL)
일부 IdP는 authorization code TTL이 매우 짧습니다(30초~2분). 모바일 딥링크, 사용자 지연, 느린 네트워크, 백엔드 큐잉이 겹치면 토큰 교환 시점에 이미 만료되어 invalid_grant가 납니다.
해결
- 콜백 수신 즉시 토큰 교환(불필요한 API 호출/렌더링 전에 처리)
- 모바일/데스크톱 브릿지에서 딥링크 지연이 있다면 flow 재설계
- 서버/클라이언트 시간 오차도 함께 점검(아래 9번 참고)
6) 클라이언트/앱이 다름(잘못된 client_id 또는 앱 설정 혼선)
인가 코드는 특정 client_id에 귀속됩니다. 인가 요청은 A 클라이언트로 했는데 토큰 교환을 B 클라이언트로 하면 invalid_grant가 납니다.
흔한 원인
- 스테이징/프로덕션 client_id를 혼용
- 모바일 앱과 웹 앱의 client_id가 다른데 공통 콜백 처리에서 섞임
- 멀티 테넌트에서 테넌트별 client_id 매핑 오류
해결
- 인가 요청을 만들 때 사용한
client_id를 함께 저장하고 동일 값으로 토큰 교환 - 환경 변수/시크릿을 배포 단위로 명확히 분리
7) code_challenge_method/엔드포인트 인코딩 불일치
인가 요청에서 code_challenge_method=S256를 보냈는데 실제 challenge가 plain처럼 만들어졌거나, 반대로 plain인데 해시를 한 값을 넣으면 매칭이 깨집니다.
또한 application/x-www-form-urlencoded 인코딩이 깨져 code_verifier가 변형되는 경우도 있습니다(프록시/라이브러리/미들웨어에서 +를 공백으로 바꾸는 등).
해결
- 인가 요청 파라미터를 로깅(민감정보 제외)해 method/challenge를 확인
- 토큰 요청은 반드시
Content-Type: application/x-www-form-urlencoded - verifier는 base64url로 만들어
+자체가 나오지 않게(3번과 연결)
8) SPA/모바일에서 code_verifier 저장소가 날아감(세션 분리)
PKCE는 인가 요청 시 생성한 code_verifier를 토큰 교환 시점까지 보관해야 합니다. 그런데 실제 환경에서는 저장소가 종종 날아갑니다.
대표 케이스
- Safari ITP/프라이빗 모드에서 서드파티 쿠키/스토리지 제약
- 모바일에서 외부 브라우저 → 앱 복귀 과정에서 세션이 끊김
- 멀티 탭/멀티 윈도우: 다른 탭에서 verifier를 덮어씀
해결 전략
- 웹 SPA라면
sessionStorage가 일반적으로 안전(탭 단위) - 멀티 탭을 허용해야 한다면
state키로 verifier를 맵핑 저장 - 가능하면 BFF(Backend for Frontend) 패턴으로 verifier를 서버 세션에 저장
예시: state별 verifier 저장(브라우저)
// authorize 시작
const state = crypto.randomUUID();
const { codeVerifier, codeChallenge } = createPkcePair();
sessionStorage.setItem(`pkce:${state}`, codeVerifier);
const authorizeUrl = new URL("https://auth.example.com/oauth/authorize");
authorizeUrl.searchParams.set("response_type", "code");
authorizeUrl.searchParams.set("client_id", CLIENT_ID);
authorizeUrl.searchParams.set("redirect_uri", REDIRECT_URI);
authorizeUrl.searchParams.set("state", state);
authorizeUrl.searchParams.set("code_challenge", codeChallenge);
authorizeUrl.searchParams.set("code_challenge_method", "S256");
location.href = authorizeUrl.toString();
// callback
const params = new URLSearchParams(location.search);
const callbackState = params.get("state");
const code = params.get("code");
const verifier = sessionStorage.getItem(`pkce:${callbackState}`);
if (!verifier) throw new Error("Missing code_verifier (storage cleared or state mismatch)");
9) 서버 시간/검증 정책 문제(Clock skew, nonce/state 검증 실패)
표준적으로 invalid_grant는 code 관련이지만, 일부 IdP/프레임워크는 다음 실패도 invalid_grant로 반환합니다.
- 서버 시간이 크게 틀어져 만료 판단이 잘못됨
state검증 실패(저장된 state와 콜백 state 불일치)- OIDC를 함께 쓰는 경우
nonce검증 실패를 뭉개서 반환
해결
- 서버 NTP 동기화(컨테이너/노드 시간 점검)
state는 CSRF 방어의 핵심이므로 “검증을 끄기”보다 저장/조회 경로를 안정화- 로깅:
state의 해시(원문 금지), 요청 시각, 콜백 시각, code 수신 시각을 남겨 시간축을 재구성
> 인프라에서 “시간/네트워크로 인해 인증이 간헐 실패”하는 패턴은 생각보다 많습니다. 예를 들어 클러스터 내부 DNS/네트워크 이슈는 증상이 모호하게 나타날 수 있는데, 이런 류의 진단 루틴은 EKS CoreDNS CrashLoopBackOff - upstream 타임아웃 해결 같은 글의 접근법(관측 지점 확보 → 원인 분해)을 OAuth 트러블슈팅에도 그대로 적용할 수 있습니다.
빠른 체크리스트(현장용)
아래 순서대로 보면 보통 10~20분 안에 범위를 좁힐 수 있습니다.
- redirect_uri: authorize와 token에서 완전 동일한가?
- code 재사용: token 요청이 2번 이상 나가지 않았나(리트라이/중복 실행)?
- code 만료: code 받은 시각 → token 호출 시각이 TTL 안인가?
- client_id: authorize에 쓴 client_id와 token의 client_id가 같은가?
- PKCE 매칭: code_verifier를 새로 만들지 않았나? 저장소에서 제대로 읽히나?
- verifier 규격: 길이 43~128, base64url, padding 제거했나?
- method 일치: S256/plain이 서로 맞나?
- 스토리지/세션: 모바일/사파리/멀티탭에서 verifier가 날아가지 않나?
- 시간/정책: 서버 시간, state/nonce 검증 로직이 안정적인가?
디버깅 팁: “무엇을 로그로 남길 것인가”
민감정보를 그대로 남기면 안 되지만, 다음은 해시/마스킹 형태로 남기면 원인 파악에 큰 도움이 됩니다.
state해시(예: SHA-256 앞 8바이트)redirect_uri원문(민감정보가 아니라면)client_id(공개 식별자)code는 원문 저장 금지, 대신 길이/앞 4글자 정도만(정책에 따라)code_verifier는 원문 저장 금지, 대신 길이 및 해시- authorize 요청 시각/콜백 수신 시각/token 요청 시각
이런 “관측 가능성”을 올려두면, 장애가 재현되지 않아도 로그만으로 1)~9) 중 어디인지 거의 판별됩니다.
마무리
PKCE를 적용했는데도 invalid_grant가 뜨는 경우는 대체로 PKCE 자체 구현 버그(2,3,7,8) 또는 **코드/리다이렉트/시간의 불일치(1,4,5,6,9)**로 정리됩니다. 특히 redirect_uri 완전 일치, code 1회성, verifier 저장 안정성 이 세 가지가 전체의 대부분을 차지합니다.
운영 환경에서 간헐적으로만 뜬다면 “중복 호출(4)”과 “스토리지/세션 소실(8)”, “짧은 TTL(5)”부터 의심하고, 로그를 시간축으로 재구성해 보세요. 성능/장애 진단을 체계화하는 방식은 프런트 성능 이슈에서 Long Task를 추적하듯(Chrome INP 폭증? Long Task 원인·해결 가이드) 인증 이슈에도 그대로 통합니다.