Published on

OAuth2 PKCE에서 invalid_grant 뜨는 9가지 원인

Authors

서드파티 로그인이나 사내 SSO를 붙이다 보면, Authorization Code + PKCE 플로우에서 토큰 교환 단계에 invalid_grant가 터지는 순간이 있습니다. 문제는 이 에러가 “그랜트가 유효하지 않다”는 뭉뚱그린 메시지라서, 실제 원인(코드 만료, 리다이렉트 불일치, code_verifier 불일치 등)을 로그로 좁혀가야 한다는 점입니다.

이 글은 PKCE 환경에서 invalid_grant가 발생하는 대표적인 9가지 케이스를, 어디를 확인해야 하는지어떻게 고치는지 중심으로 정리합니다. Auth0를 쓰는 경우라면 더 구체적인 체크리스트는 아래 글도 함께 참고하세요.


PKCE에서 invalid_grant가 주로 터지는 지점

PKCE 플로우는 대략 다음 순서로 진행됩니다.

  1. 클라이언트가 code_verifier(랜덤 문자열) 생성
  2. code_challenge = BASE64URL(SHA256(code_verifier)) 계산 후 /authorize 요청
  3. 인증 서버가 authorization_code 발급
  4. 클라이언트가 /tokencode + code_verifier로 교환

invalid_grant는 보통 4번, 즉 /token 요청에서 발생합니다. 따라서 디버깅은 “/authorize 때 보낸 값”과 “/token 때 보낸 값”의 일치성을 검증하는 방향으로 진행하는 게 빠릅니다.


1) redirect_uri 불일치 (가장 흔함)

증상

  • /authorize는 정상적으로 동작하고 콜백 URL로 code가 돌아오지만
  • /token에서 invalid_grant

원인

OAuth2 스펙상 authorization code는 발급 시점의 redirect_uri와 토큰 교환 시점의 redirect_uri가 완전히 동일해야 합니다.

다음 차이도 불일치로 처리될 수 있습니다.

  • http vs https
  • trailing slash 유무: https://app.example.com/callback vs https://app.example.com/callback/
  • 쿼리스트링 포함 여부
  • 포트 번호 포함 여부

해결

  • /authorize/token동일한 문자열을 보내도록 고정
  • 환경별로 redirect_uri를 조합하지 말고, 가능한 한 설정값으로 단일화
# /token 요청 예시 (curl)
curl -sS -X POST https://idp.example.com/oauth/token \
  -H 'content-type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=authorization_code' \
  --data-urlencode 'client_id=YOUR_CLIENT_ID' \
  --data-urlencode 'code=AUTH_CODE_FROM_CALLBACK' \
  --data-urlencode 'redirect_uri=https://app.example.com/callback' \
  --data-urlencode 'code_verifier=YOUR_CODE_VERIFIER'

2) code_verifier를 저장/복원 못함 (SPA, 모바일에서 자주 발생)

증상

  • 로그인 리다이렉트 이후 앱이 새로고침되거나 프로세스가 재시작되면
  • /token에서 invalid_grant

원인

code_verifier/authorize 요청 전에 생성되고, 콜백 이후 /token 요청 때 그대로 필요합니다. 즉, 리다이렉트 사이에 안전하게 보관되어야 합니다.

자주 깨지는 패턴

  • 브라우저 메모리 변수에만 저장(리다이렉트 후 초기화)
  • iOS/Android에서 웹뷰/프로세스가 중간에 죽음
  • 서버에서 상태를 관리해야 하는데 stateless로 구현

해결

  • SPA: sessionStorage 등에 저장(보안 요구에 따라 적절히 선택)
  • 서버 기반: 서버 세션(또는 짧은 TTL의 서버 저장소)에 state 키로 매핑
// SPA 예시: 리다이렉트 전
const verifier = generateVerifier();
sessionStorage.setItem('pkce_verifier', verifier);

// 콜백 후 토큰 교환 전
const saved = sessionStorage.getItem('pkce_verifier');
if (!saved) throw new Error('missing code_verifier');

3) code_challenge_method 불일치 또는 누락

증상

  • 어떤 IdP는 기본이 plain이고, 어떤 IdP는 S256만 허용
  • 환경에 따라 간헐적으로 invalid_grant

원인

/authorize에서 code_challenge_method=S256로 보냈다면, 서버는 S256 기준으로 검증합니다. 그런데 클라이언트가 실제로는 plain처럼 계산하거나, 반대로 서버가 plain만 기대하는데 S256로 보낼 수도 있습니다.

해결

  • IdP 설정에서 허용하는 메서드를 확인
  • 클라이언트는 가급적 S256 고정
/authorize?...&code_challenge_method=S256&code_challenge=... 

Node.js에서 S256 계산 예시입니다.

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).digest();
  return base64url(hash);
}

4) Base64URL 인코딩 실수 (패딩, 문자 치환)

증상

  • 구현은 맞아 보이는데 계속 invalid_grant
  • 특히 직접 구현한 PKCE 유틸에서 발생

원인

PKCE의 code_challengeBase64가 아니라 Base64URL입니다.

  • +-
  • /_
  • trailing = 패딩 제거

이 중 하나라도 틀리면 서버가 계산한 값과 불일치합니다.

해결

  • 검증된 라이브러리 사용
  • 직접 구현 시 Base64URL 변환을 정확히 적용
// 나쁜 예: base64 그대로 사용하면 + / = 가 남을 수 있음
hash.toString('base64');

// 좋은 예: base64url로 변환
base64url(hash);

5) authorization_code 재사용 (중복 교환)

증상

  • 첫 번째 시도는 성공
  • 네트워크 재시도나 앱 로직 중복 호출 후 두 번째부터 invalid_grant

원인

Authorization code는 일회성입니다. 한 번 /token으로 교환되면 즉시 무효화됩니다.

이런 상황에서 흔합니다.

  • 프론트에서 토큰 교환을 두 번 호출(라우팅 이벤트 중복)
  • 백엔드에서 타임아웃으로 재시도했는데 실제로는 IdP가 처리 완료
  • 콜백 엔드포인트가 두 번 hit(프록시, 리다이렉트 체인)

해결

  • 콜백 처리 로직에 멱등성 키 적용(예: code를 DB에 저장하고 1회만 처리)
  • 재시도 시나리오에서는 IdP 응답을 기준으로 안전하게 처리
// 의사코드: code 1회 처리 보장
if (await usedCodeStore.has(code)) {
  throw new Error('code already used');
}
await usedCodeStore.add(code, { ttlSeconds: 300 });
// then exchange token

6) 코드 만료 또는 서버 시간 불일치

증상

  • 사용자가 로그인 화면에서 오래 머무르면 실패
  • 특정 서버에서만 실패(멀티 리전/멀티 노드)

원인

Authorization code는 짧은 TTL(보통 수십 초~수분)을 가집니다. 또한 서버가 iat 같은 시간 기반 검증을 하거나, 내부적으로 만료를 계산할 때 서버 시간이 틀어져 있으면 정상 코드도 만료로 처리될 수 있습니다.

해결

  • 코드 발급 후 빠르게 교환(콜백 처리 지연 최소화)
  • 서버/컨테이너 NTP 동기화 확인

인프라에서 TLS/인증서 신뢰 문제가 함께 보이면 시간/CA 구성도 같이 점검하세요.


7) client_id 또는 앱 타입 설정 불일치 (Public vs Confidential)

증상

  • 어떤 환경에서는 되는데, 특정 클라이언트 설정에서만 invalid_grant

원인

PKCE는 주로 Public client(네이티브, SPA)에서 사용합니다. 그런데 IdP 설정이 Confidential client로 되어 있고, 토큰 엔드포인트에서 client authentication을 기대하거나, 반대로 Public로 되어 있는데 client secret을 보내는 등 설정이 꼬이면 invalid_grant 또는 유사 에러가 발생할 수 있습니다.

해결

  • IdP에서 애플리케이션 타입과 토큰 엔드포인트 인증 방식을 명확히 설정
  • Public client라면 client_secret을 쓰지 않는 구성을 우선 고려
# Public client에서 흔한 형태: client_secret 없이 교환
--data-urlencode 'client_id=YOUR_CLIENT_ID'

8) state/세션 매핑 꼬임 (다중 탭, 병렬 로그인)

증상

  • 사용자가 탭을 여러 개 열고 로그인하거나
  • 로그인 버튼을 연속 클릭하면
  • 간헐적으로 invalid_grant

원인

엄밀히 말해 state 불일치는 invalid_state로 처리되는 경우가 많지만, 구현에 따라 state로 매핑해둔 code_verifier를 잘못 꺼내오면서 결과적으로 /token에서 invalid_grant가 납니다.

예시

  • state=A에 대한 code_verifier를 저장
  • 사용자가 새 탭에서 state=B로 다시 로그인
  • 콜백에서 state=A가 왔는데 저장소에는 B만 남아 있음

해결

  • state를 키로 code_verifier를 저장하고, 다중 엔트리를 허용
  • 한 사용자 세션에서 동시에 여러 PKCE 트랜잭션이 가능하도록 설계
// 의사코드: state별로 verifier 저장
await store.set(`pkce:${state}`, verifier, { ttlSeconds: 600 });

// 콜백에서
const verifier = await store.get(`pkce:${state}`);
if (!verifier) throw new Error('verifier not found for state');

9) 토큰 엔드포인트 요청 포맷 오류 (특히 application/x-www-form-urlencoded)

증상

  • 서버 로그에는 값이 제대로 찍히는 것 같은데
  • IdP는 invalid_grant 또는 모호한 에러

원인

OAuth2 토큰 요청은 일반적으로 application/x-www-form-urlencoded를 요구합니다. 그런데 JSON으로 보내거나, URL 인코딩이 깨져서 code_verifier가 변형되면 서버 입장에서는 검증 실패로 invalid_grant를 반환할 수 있습니다.

특히 code_verifier는 길고 특수문자가 포함될 수 있어, 인코딩이 매우 중요합니다.

해결

  • 반드시 application/x-www-form-urlencoded로 전송
  • fetch 사용 시 URLSearchParams 활용
// Node.js/브라우저 공통 예시
const body = new URLSearchParams({
  grant_type: 'authorization_code',
  client_id: process.env.CLIENT_ID,
  code,
  redirect_uri: 'https://app.example.com/callback',
  code_verifier: verifier,
});

const res = await fetch('https://idp.example.com/oauth/token', {
  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)}`);
}

실전 디버깅 체크리스트 (로그로 빠르게 좁히기)

아래 6가지를 한 번에 로그로 남기면 원인 파악이 빨라집니다. 단, 보안상 운영 로그에는 마스킹을 권장합니다.

  • /authorize 요청 시 사용한 redirect_uri
  • /token 요청 시 사용한 redirect_uri
  • code_challenge_method
  • code_challenge (앞 6~10자만)
  • code_verifier (길이만, 또는 앞 6~10자만)
  • code (앞 6~10자만)
function mask(s, n = 8) {
  if (!s) return '(null)';
  return `${s.slice(0, n)}...len=${s.length}`;
}

console.log({
  redirectAuthorize: redirect1,
  redirectToken: redirect2,
  method: 'S256',
  codeChallenge: mask(codeChallenge),
  codeVerifier: mask(codeVerifier),
  code: mask(code),
});

마무리

PKCE에서 invalid_grant는 대부분 “PKCE 값이 틀렸다”라기보다, 리다이렉트/세션/재시도/인코딩 같은 경계면에서 값이 한 글자라도 달라져 발생합니다. 위 9가지를 순서대로 점검하면, 대개는 redirect_uri 일치와 code_verifier 보관 방식에서 답이 나옵니다.

추가로, IdP가 Auth0라면 케이스별로 더 구체적인 로그 포인트와 대처법을 정리한 글도 함께 보면 문제를 더 빨리 좁힐 수 있습니다.