Published on

OAuth PKCE인데 invalid_grant 뜨는 9가지

Authors

서드파티 로그인(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/callback vs https://app/callback/
  • 스킴 차이: http vs https
  • 포트 차이: https://localhost:3000/callback vs https://localhost/callback
  • 쿼리스트링 포함 여부: .../callback?foo=1 vs .../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분 안에 범위를 좁힐 수 있습니다.

  1. redirect_uri: authorize와 token에서 완전 동일한가?
  2. code 재사용: token 요청이 2번 이상 나가지 않았나(리트라이/중복 실행)?
  3. code 만료: code 받은 시각 → token 호출 시각이 TTL 안인가?
  4. client_id: authorize에 쓴 client_id와 token의 client_id가 같은가?
  5. PKCE 매칭: code_verifier를 새로 만들지 않았나? 저장소에서 제대로 읽히나?
  6. verifier 규격: 길이 43~128, base64url, padding 제거했나?
  7. method 일치: S256/plain이 서로 맞나?
  8. 스토리지/세션: 모바일/사파리/멀티탭에서 verifier가 날아가지 않나?
  9. 시간/정책: 서버 시간, 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 원인·해결 가이드) 인증 이슈에도 그대로 통합니다.