Published on

OAuth PKCE invalid_grant 6가지 원인·해결

Authors

서드파티 로그인이나 모바일 로그인에서 PKCE를 붙였는데, token 엔드포인트에서 invalid_grant가 떨어지면 디버깅 난이도가 급격히 올라갑니다. 이유는 간단합니다. invalid_grant는 "그랜트가 유효하지 않다"라는 넓은 범주의 에러로, 실제 원인(코드 불일치, 리다이렉트 불일치, 만료, 재사용, verifier 인코딩 문제 등)을 서버가 뭉뚱그려 반환하는 경우가 많기 때문입니다.

이 글은 PKCE 기반 Authorization Code Flow에서 invalid_grant가 발생하는 대표 원인 6가지를 재현 포인트, 확인 로그, 해결 방법 중심으로 정리합니다. (특정 IdP 구현에 따라 메시지/상세 필드는 다를 수 있습니다.)

빠른 전제: PKCE가 붙은 Authorization Code Flow

흐름을 최소한으로 정리하면 아래와 같습니다.

  1. 클라이언트가 code_verifier(고엔트로피 문자열)를 생성
  2. code_verifiercode_challenge를 생성 (보통 S256)
  3. /authorize 요청에 code_challengecode_challenge_method=S256를 포함
  4. 리다이렉트로 authorization_code를 수령
  5. /token 요청에서 code와 **원본 code_verifier**를 제출해 교환

invalid_grant는 주로 5번에서 터집니다.

진단을 쉽게 만드는 최소 로깅 체크리스트

클라이언트(앱/웹)와 백엔드(BFF가 있다면)에서 아래를 반드시 남기면 원인 분류가 빨라집니다.

  • state 값 (authorize 요청 시점, callback 수신 시점)
  • redirect_uri (authorize 요청 시점, token 요청 시점)
  • code_verifier 길이와 일부 마스킹된 프리픽스/서픽스 (원문 전체는 보안상 비권장)
  • code_challenge (authorize 요청에 넣은 값)
  • 토큰 요청의 grant_type, client_id, code 길이
  • 토큰 응답의 error, error_description, error_uri

네트워크 타이밍 문제도 종종 섞이므로, 요청 시각(밀리초)과 재시도 여부도 기록하세요. 타임아웃/재시도 전략은 에러 디버깅에서 중요한데, 비슷한 맥락으로는 OpenAI 429 Rate Limit 해결 - 백오프·큐·배치 글의 "재시도는 멱등성과 결합해 설계" 관점이 OAuth에서도 그대로 적용됩니다.

원인 1) code_verifier 불일치 (저장/전달/동시성 문제)

증상

  • /authorize는 정상, callback으로 code도 정상 수신
  • /token에서 invalid_grant
  • SPA에서 특히 빈번: 탭 2개, 로그인 버튼 연타, 라우팅 전환 등

대표 실수

  • code_verifier를 메모리 변수에만 저장했다가 리다이렉트/새로고침으로 유실
  • 여러 로그인 시도를 동시에 시작해 마지막 verifier로 덮어쓰기
  • iOS/Android에서 WebView와 앱 간 브리지에서 verifier가 깨짐

해결

  • 로그인 시도 단위로 code_verifier키드 저장: 키는 state 또는 자체 login_attempt_id
  • callback에서 state로 정확히 매칭된 verifier를 꺼내 토큰 교환
  • 탭/동시성 제어: 로그인 버튼 디바운스, 진행 중이면 새 시도 차단

예시: 브라우저에서 state로 verifier 매핑

// pkce.ts
function base64UrlEncode(bytes: ArrayBuffer) {
  const bin = String.fromCharCode(...new Uint8Array(bytes));
  return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}

export async function sha256Base64Url(input: string) {
  const data = new TextEncoder().encode(input);
  const digest = await crypto.subtle.digest("SHA-256", data);
  return base64UrlEncode(digest);
}

export function randomVerifier(len = 64) {
  // RFC 7636: 43~128 chars
  const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
  const arr = new Uint8Array(len);
  crypto.getRandomValues(arr);
  return Array.from(arr, (x) => chars[x % chars.length]).join("");
}
// startLogin.ts
import { randomVerifier, sha256Base64Url } from "./pkce";

export async function startLogin() {
  const state = crypto.randomUUID();
  const verifier = randomVerifier(64);
  const challenge = await sha256Base64Url(verifier);

  sessionStorage.setItem(`pkce:${state}:verifier`, verifier);

  const params = new URLSearchParams({
    response_type: "code",
    client_id: "my-client",
    redirect_uri: "https://app.example.com/callback",
    scope: "openid profile",
    state,
    code_challenge: challenge,
    code_challenge_method: "S256",
  });

  location.href = `https://idp.example.com/authorize?${params.toString()}`;
}
// callback.ts
export function getVerifierOrThrow(state: string) {
  const key = `pkce:${state}:verifier`;
  const verifier = sessionStorage.getItem(key);
  if (!verifier) throw new Error("Missing code_verifier for state");
  sessionStorage.removeItem(key);
  return verifier;
}

원인 2) redirect_uri 불일치 (authorize와 token의 값이 다름)

증상

  • IdP 문서에는 "redirect_uri must match"라고 되어 있는데, 실제로는 invalid_grant만 반환
  • 로컬/스테이징/프로덕션에서만 재현

대표 실수

  • authorize 요청에 redirect_uri=https://app.example.com/callback을 사용했는데 token 요청에서는 https://app.example.com/callback/처럼 슬래시가 붙음
  • 쿼리스트링 포함 여부 차이: callback?from=login을 authorize에 넣고 token에서는 제거
  • 프록시/로드밸런서 뒤에서 외부 URL과 내부 URL이 다름

해결

  • authorize와 token에서 완전히 동일한 문자열을 사용 (정규화 기대 금지)
  • 가능하면 redirect_uri를 하드코딩된 상수로 관리하고, 환경별로 명시적으로 분기
  • BFF 패턴이면 외부에서 보이는 URL(예: X-Forwarded-Proto, X-Forwarded-Host)을 기반으로 redirect를 만들되, 최종 문자열을 고정

예시: Node/Express에서 redirect URI를 단일 소스로 고정

// config.js
export const REDIRECT_URI = process.env.OAUTH_REDIRECT_URI;
if (!REDIRECT_URI) throw new Error("OAUTH_REDIRECT_URI is required");
// tokenExchange.js
import { REDIRECT_URI } from "./config.js";

export async function exchangeToken({ code, verifier }) {
  const body = new URLSearchParams({
    grant_type: "authorization_code",
    client_id: process.env.OAUTH_CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    code,
    code_verifier: verifier,
  });

  const res = await fetch(process.env.OAUTH_TOKEN_URL, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body,
  });

  const json = await res.json();
  if (!res.ok) throw new Error(`token exchange failed: ${JSON.stringify(json)}`);
  return json;
}

원인 3) code 만료 또는 시계/지연 문제 (TTL 초과)

증상

  • 사용자 경험상 로그인 과정이 길어질 때(인증 앱 확인, MFA, 네트워크 지연) 발생
  • 모바일에서 백그라운드 전환 후 복귀 시 자주 발생

원리

Authorization Code는 보통 수십 초에서 수 분 내 만료됩니다. 또한 일부 IdP는 코드 발급 이후 아주 짧은 교환 유효시간을 두기도 합니다.

해결

  • callback을 받자마자 즉시 token 교환 (클라이언트에서 지연 작업 금지)
  • 모바일: 딥링크 콜백 후 앱 복귀 시 즉시 교환, 백그라운드에서 verifier/code를 잃지 않게 저장
  • 서버: 토큰 교환 API에 타임아웃을 짧게 두고 실패 시 "처음부터 로그인 재시작"으로 유도

네트워크 지연/타임아웃이 섞이면 원인 파악이 더 어려워집니다. 분산 환경에서 타임아웃 전파가 안 되면 "재시도하다가 만료" 같은 2차 장애가 생기기도 하니, 관점 확장을 원하면 gRPC MSA 데드라인 전파 누락 진단·해결도 함께 참고할 만합니다.

원인 4) Authorization Code 재사용 (중복 교환)

증상

  • 첫 번째 교환은 성공
  • 같은 code로 두 번째 교환 시 invalid_grant
  • 간헐적으로만 재현: 재시도 로직, 더블 클릭, 백엔드 중복 처리

대표 실수

  • 프론트에서 token 교환을 호출했는데 네트워크 오류로 실패했다고 판단하고 재시도했으나, 실제로는 서버가 성공 처리
  • BFF가 콜백을 두 번 처리 (예: 라우팅 중복, 웹훅/콜백 엔드포인트가 두 번 호출)

해결

  • code는 1회성임을 전제로, 교환 요청을 멱등하게 만들려면 서버 측에서만 제어해야 합니다.
  • BFF 사용 시: code를 키로 짧은 TTL의 "교환 처리 중" 락을 걸고, 중복 요청을 차단
  • 클라이언트 재시도는 신중히: 토큰 교환은 재시도 시도가 곧 재사용이 될 수 있으므로, 네트워크 오류 시 "로그인 다시 시작" UX가 더 안전한 경우가 많습니다.

예시: Redis로 code 중복 교환 방지(개념)

// pseudo-code
// SET key value NX EX 60  형태로 60초 동안 중복 교환 차단
const lockKey = `oauth:code:${code}`;
const ok = await redis.set(lockKey, "1", { NX: true, EX: 60 });
if (!ok) {
  throw new Error("Duplicate token exchange detected");
}

try {
  return await exchangeToken({ code, verifier });
} finally {
  // 성공/실패에 따라 삭제 정책은 선택
  // 실패 시에도 즉시 삭제하면 폭주 재시도가 가능해짐
}

원인 5) PKCE 인코딩/해시 생성 오류 (Base64URL, S256, 길이)

증상

  • 어떤 환경에서는 되고, 어떤 환경에서는 안 됨 (브라우저별, RN/네이티브별)
  • code_verifier를 눈으로 보면 "그럴듯"한데 계속 실패

체크 포인트

  • code_challenge_methodS256인데 실제로는 plain challenge를 넣음
  • Base64가 아니라 Base64URL이어야 함: +/-_로 바꾸고 패딩 = 제거
  • code_verifier 길이: RFC 7636 권고 범위(43~128)
  • 문자셋: A-Z a-z 0-9 - . _ ~ 이외 문자를 섞지 않는 것이 안전

해결

  • 검증된 라이브러리 사용 권장
  • 직접 구현 시: Base64URL 변환과 패딩 제거를 반드시 포함
  • 서버/클라이언트에서 같은 방식으로 생성했는지 테스트 벡터로 확인

예시: S256 계산 결과를 로컬에서 재검증

# verifier를 환경변수로 두고, S256 challenge를 계산해 비교 (macOS/Linux)
VERIFIER='your_verifier_here'
printf "%s" "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '='

이 출력이 authorize 요청에 넣은 code_challenge와 다르면, 구현/인코딩이 어긋난 것입니다.

원인 6) 클라이언트 타입/인증 방식 불일치 (public vs confidential)

증상

  • 특정 IdP에서만 발생
  • 문서에는 PKCE를 쓰라고 되어 있는데, 동시에 client_secret 요구 또는 금지 정책이 있음

대표 케이스

  • SPA/모바일(공개 클라이언트)인데 token 요청에 client_secret을 포함하거나, 반대로 confidential 클라이언트인데 secret/인증 헤더가 누락
  • client_id가 다른 앱의 것으로 섞임 (환경 변수/빌드 설정 실수)

해결

  • IdP 콘솔에서 앱 유형을 확인: public client인지 confidential client인지
  • confidential이면 token 요청에 client_secret 또는 client_assertion 등 요구사항을 정확히 맞춤
  • public이면 secret을 절대 앱에 포함하지 말고, PKCE만으로 교환하도록 구성

예시: confidential client의 Basic 인증(개념)

const basic = Buffer.from(
  `${process.env.OAUTH_CLIENT_ID}:${process.env.OAUTH_CLIENT_SECRET}`
).toString("base64");

const res = await fetch(process.env.OAUTH_TOKEN_URL, {
  method: "POST",
  headers: {
    "Content-Type": "application/x-www-form-urlencoded",
    "Authorization": `Basic ${basic}`,
  },
  body: new URLSearchParams({
    grant_type: "authorization_code",
    code,
    redirect_uri: REDIRECT_URI,
    code_verifier: verifier,
  }),
});

IdP에 따라 Basic을 금지하고 body 파라미터로만 허용하기도 하니, 문서/콘솔 설정을 최우선으로 맞추세요.

실전 디버깅 순서(추천)

  1. redirect_uri 완전 일치부터 확인 (가장 흔하고, 가장 빨리 잡힘)
  2. callback에서 받은 stateverifier 매핑이 정확한지 확인
  3. code_challenge를 로컬에서 재계산해 PKCE 인코딩 검증
  4. code만료되기 전에 바로 교환되는지 확인 (사용자 체류 시간, 앱 백그라운드)
  5. 중복 교환 여부 확인 (재시도, 더블 클릭, 서버 중복 처리)
  6. 클라이언트 인증 방식(public/confidential)과 token 요청 헤더/바디를 점검

이 순서는 "발생 빈도"와 "확인 비용"을 기준으로 정렬했습니다.

운영에서 재발을 줄이는 가드레일

  • 로그인 시도 단위 식별자(state)를 중심으로 PKCE 데이터를 저장하고, callback에서 1회 사용 후 제거
  • token 교환 요청은 되도록 서버(BFF)에서 수행해 동시성/재시도/로그를 통제
  • 토큰 교환 실패(invalid_grant)는 대부분 자동 복구가 어렵습니다. UX는 "다시 로그인"으로 유도하되, 내부적으로는 원인 분류가 가능하도록 로그 필드를 구조화
  • 배포/환경 변수 변경 시 redirect_uri, client_id가 섞이지 않도록 체크리스트화

분산 시스템에서 "간헐적"으로 보이는 문제는 동시성/재시도/락 설계가 원인인 경우가 많습니다. 그런 유형의 사고 대응 감각을 확장하려면 Kafka Exactly-Once 깨질 때 진단 7단계처럼 "한 번만 처리돼야 하는 것"을 어떻게 보장하고 관측할지에 대한 접근이 OAuth 코드 교환에도 도움이 됩니다.

마무리

PKCE에서 invalid_grant는 하나의 에러 코드지만, 실제로는 값 불일치, 만료, 재사용, 인코딩 오류, 클라이언트 인증 정책 불일치 같은 서로 다른 실패 모드가 같은 외피로 나타나는 경우가 많습니다. 위 6가지를 체크리스트로 삼아 redirect_uriverifier 매핑부터 차근차근 좁혀가면, 대부분은 30분 내 원인에 도달할 수 있습니다.