Published on

OAuth2 PKCE에서 invalid_grant 뜨는 9가지

Authors

OAuth2 Authorization Code + PKCE를 붙이면 보안은 좋아지지만, 토큰 교환 단계에서 invalid_grant가 한 번 뜨기 시작하면 원인 추적이 꽤 까다롭습니다. 특히 PKCE는 code_verifier 하나만 틀려도 서버 입장에서는 “그냥 그랜트가 유효하지 않다”로 뭉뚱그려 응답하는 경우가 많습니다.

이 글은 “토큰 엔드포인트에서 invalid_grant가 뜨는 경우”를 기준으로, 현장에서 자주 만나는 원인 9가지를 빠르게 좁혀가는 방식으로 정리합니다. (IdP는 Keycloak, Auth0, Cognito, Okta 등 대부분 유사하게 적용됩니다.)

먼저 확인: invalid_grant가 의미하는 범위

invalid_grant는 RFC 6749에서 “제공된 authorization grant 또는 refresh token이 유효하지 않거나, 만료되었거나, 취소되었거나, redirect URI가 일치하지 않거나, 다른 클라이언트에 발급되었다” 같은 상황을 포괄합니다. PKCE에서는 여기에 “code_verifier 불일치”가 사실상 가장 많이 추가됩니다.

즉, 서버는 대개 아래 중 하나를 의심합니다.

  • code 자체가 잘못됐거나(만료/재사용/클라이언트 불일치)
  • redirect_uri가 다르거나
  • PKCE 검증이 실패했거나
  • 토큰 요청 파라미터가 표준과 다르게 들어왔거나

디버깅 준비: 토큰 요청을 1:1로 재현하기

가장 먼저 해야 할 일은 “앱 코드에서 보내는 토큰 요청”을 그대로 복제해서 재현 가능한 형태로 만드는 것입니다.

curl로 토큰 교환 재현

아래는 전형적인 PKCE 토큰 교환 요청입니다. client_secret이 없는 퍼블릭 클라이언트(모바일/SPA) 기준입니다.

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=my-client" \
  --data-urlencode "code=AUTH_CODE_FROM_CALLBACK" \
  --data-urlencode "redirect_uri=com.example.app:/oauth/callback" \
  --data-urlencode "code_verifier=VERIFIER_USED_AT_AUTH_REQUEST"

curl이 성공하는데 앱만 실패하면 “앱이 실제로 보내는 값이 다르다”는 뜻입니다. 반대로 curl도 실패하면 서버 설정/파라미터 불일치 가능성이 큽니다.

서버 로그에서 꼭 봐야 할 것

IdP 로그에서 다음 키워드를 찾으면 원인 좁히기가 빨라집니다.

  • PKCE verification failed
  • invalid redirect_uri
  • code is expired / code already used
  • client mismatch

(로그가 빈약하면, 토큰 엔드포인트 앞단에 프록시를 두고 요청 바디를 마스킹 후 로깅하는 것도 방법입니다.)

1) code_verifier가 Authorization 요청 때 것과 다름

가장 흔한 원인입니다. Authorization 요청에서 생성한 code_verifier를 콜백에서 토큰 교환까지 “같은 값”으로 유지해야 합니다.

흔한 실수 패턴

  • 앱이 재시작되면서 메모리에서 code_verifier가 날아감
  • 여러 로그인 탭/웹뷰가 동시에 열려 마지막 code_verifier로 덮어씀
  • 콜백 처리 전에 code_verifier를 갱신해버림

해결 체크

  • state를 키로 해서 code_verifier를 저장하고, 콜백의 state로 정확히 복원
  • 멀티 탭을 허용한다면 state별로 별도 저장소에 보관

예시: Node.js에서 state 기반 저장

// (예시) 메모리 저장소 - 운영에서는 세션/Redis 권장
const pkceStore = new Map();

function startLogin(req, res) {
  const state = crypto.randomUUID();
  const codeVerifier = base64url(crypto.randomBytes(32));
  const codeChallenge = base64url(crypto.createHash('sha256').update(codeVerifier).digest());

  pkceStore.set(state, codeVerifier);

  const authUrl = new URL('https://idp.example.com/oauth/authorize');
  authUrl.searchParams.set('response_type', 'code');
  authUrl.searchParams.set('client_id', 'my-client');
  authUrl.searchParams.set('redirect_uri', 'https://app.example.com/callback');
  authUrl.searchParams.set('state', state);
  authUrl.searchParams.set('code_challenge', codeChallenge);
  authUrl.searchParams.set('code_challenge_method', 'S256');

  res.redirect(authUrl.toString());
}

async function callback(req, res) {
  const { code, state } = req.query;
  const codeVerifier = pkceStore.get(state);
  pkceStore.delete(state);

  // codeVerifier가 없으면 99% 여기서부터 꼬임
  if (!codeVerifier) return res.status(400).send('missing code_verifier');

  // 이후 토큰 교환 요청에 codeVerifier를 그대로 사용
}

function base64url(buf) {
  return Buffer.from(buf)
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/g, '');
}

2) code_challenge 생성 방식이 잘못됨 (Base64URL vs Base64)

PKCE에서 S256은 아래 규칙을 엄격히 따릅니다.

  • code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
  • Base64가 아니라 Base64URL 인코딩이어야 함 (+ / = 처리)

흔한 실수 패턴

  • 일반 Base64를 그대로 사용해서 + / =가 남아있음
  • 해시 결과를 hex 문자열로 변환 후 인코딩함
  • UTF-8/ASCII 처리 혼동(대부분 UTF-8로 바이트화하면 문제 없지만, 중간 변환이 들어가면 틀어짐)

해결 체크

  • 라이브러리 사용 시 “base64url” 지원 여부 확인
  • 직접 구현 시 = 패딩 제거 포함

예시: 브라우저(Web Crypto)에서 S256 생성

async function pkceChallengeFromVerifier(verifier) {
  const data = new TextEncoder().encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  const bytes = new Uint8Array(digest);
  let base64 = btoa(String.fromCharCode(...bytes));
  return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}

3) code_challenge_method 불일치 또는 누락

Authorization 요청에서 code_challenge_method=S256를 보냈는데, 서버가 plain으로 처리하거나 반대로 서버가 S256만 허용하는데 plain을 보내면 토큰 교환에서 실패합니다.

해결 체크

  • Authorization 요청에 code_challenge_method를 명시
  • IdP 설정에서 PKCE 정책 확인 (S256 강제 여부)

4) redirect_uri가 토큰 요청에서 1바이트라도 다름

많은 IdP는 토큰 교환 요청에 포함된 redirect_uri가 “Authorization 요청 때 사용한 값” 및 “클라이언트 등록 값”과 정확히 일치해야 합니다.

흔한 실수 패턴

  • Authorization 요청에는 https://app.example.com/callback인데 토큰 요청에는 https://app.example.com/callback/ (슬래시 하나)
  • URL 인코딩 차이로 인해 실제 문자열이 달라짐
  • 모바일 커스텀 스킴에서 대소문자/콜론 처리 차이 (com.example.app:/callback vs com.example.app://callback)

해결 체크

  • Authorization 요청과 토큰 요청에서 redirect_uri를 동일한 상수로 관리
  • IdP 콘솔에 등록된 redirect URI 목록과 완전 일치 확인

5) Authorization Code가 이미 사용됨 (재사용/중복 콜백)

Authorization Code는 1회성입니다. 콜백이 두 번 처리되면(사용자가 뒤로가기, 앱이 리다이렉트 URL을 두 번 핸들링, 네트워크 재시도 등) 두 번째 토큰 교환은 invalid_grant가 됩니다.

흔한 실수 패턴

  • 콜백 라우트에서 “토큰 교환”을 idempotent하게 만들지 않음
  • 프론트/백엔드가 동시에 같은 code로 교환 시도

해결 체크

  • code를 키로 한 중복 방지(짧은 TTL 캐시)
  • 콜백 처리 로직을 단일 경로로 수렴

중복 처리 설계 자체는 OAuth에만 국한되지 않습니다. 분산 환경에서 중복 요청을 다루는 패턴은 사가/보상 트랜잭션 관점에서도 참고할 만합니다: MSA 사가(Saga) 중복처리·보상트랜잭션 설계 실전

예시: code 재사용 방지(서버)

const usedCodes = new Map(); // code -> expiresAt

function markCodeUsed(code, ttlMs = 60_000) {
  const now = Date.now();
  // 간단한 청소
  for (const [k, exp] of usedCodes) if (exp <= now) usedCodes.delete(k);
  if (usedCodes.has(code)) return false;
  usedCodes.set(code, now + ttlMs);
  return true;
}

async function handleCallback(req, res) {
  const { code } = req.query;
  if (!markCodeUsed(code)) {
    return res.status(409).send('code already handled');
  }
  // 토큰 교환 진행
}

6) Authorization Code가 만료됨 (짧은 TTL + 지연)

Authorization Code TTL은 보통 30초~수 분으로 짧습니다. 아래 상황에서 만료가 자주 납니다.

  • 모바일에서 외부 브라우저로 로그인 후 앱 복귀까지 시간이 오래 걸림
  • 콜백 처리 후 토큰 교환 전에 서버 내부 네트워크 지연/큐 적체
  • 사용자가 로그인 화면에서 오래 머뭄(특히 MFA)

해결 체크

  • IdP의 code TTL 설정 확인(가능하면 약간 늘리되 과도하게 늘리지는 않기)
  • 콜백 수신 즉시 토큰 교환(불필요한 비즈니스 로직을 앞에 두지 않기)
  • 모바일 딥링크 핸들링 지연 최소화

7) client_id/앱 등록이 서로 다른 환경으로 섞임

개발/스테이징/운영 환경을 오가다 보면 아래처럼 “Authorization은 A 클라이언트, 토큰은 B 클라이언트”로 요청이 섞여 invalid_grant가 발생합니다.

흔한 실수 패턴

  • 프론트는 스테이징 client_id로 authorize, 백엔드는 운영 토큰 엔드포인트로 교환
  • 앱 설정 파일이 캐시되어 이전 환경 값을 계속 사용
  • 멀티 테넌트에서 테넌트별 client_id 매핑이 깨짐

해결 체크

  • authorize URL, token URL, client_id, redirect URI를 “한 묶음”으로 환경별 고정
  • 콜백에서 받은 issuer(iss)나 도메인으로 환경을 역검증

8) 토큰 요청 Content-Type/바디 인코딩이 잘못됨

토큰 엔드포인트는 대개 application/x-www-form-urlencoded를 기대합니다. JSON으로 보내거나, 폼 인코딩이 깨지면 서버가 파라미터를 못 읽고 invalid_grant 또는 invalid_request로 떨어질 수 있습니다.

해결 체크

  • 헤더가 Content-Type: application/x-www-form-urlencoded인지 확인
  • code_verifier에 특수문자가 포함될 수 있으므로 URL 인코딩이 안전하게 되는지 확인

예시: fetch로 폼 인코딩 보내기

const body = new URLSearchParams({
  grant_type: 'authorization_code',
  client_id: 'my-client',
  code,
  redirect_uri: 'https://app.example.com/callback',
  code_verifier: verifier,
});

const resp = await fetch('https://idp.example.com/oauth/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body,
});

9) 클럭 스큐(time skew) 또는 잘못된 시간 동기화

의외로 “서버 시간” 때문에 invalid_grant가 나는 경우가 있습니다.

  • IdP가 code 발급/만료를 시간 기반으로 판단
  • 앱 서버/프록시의 시간이 크게 틀어져 재시도/캐시/세션이 꼬임
  • 컨테이너 노드의 NTP가 깨져 인증 흐름이 간헐적으로 실패

해결 체크

  • IdP, 게이트웨이, 앱 서버, 워커 노드의 시간 동기화(NTP) 확인
  • 장애가 간헐적이고 특정 노드에서만 발생한다면 노드별 시간 차이를 의심

실전 체크리스트: 10분 안에 원인 좁히기

아래 순서로 보면 대부분 빠르게 결론이 납니다.

  1. 토큰 요청을 curl로 재현 가능한가
  2. Authorization 요청과 토큰 요청의 redirect_uri 문자열이 완전 동일한가
  3. 콜백의 statecode_verifier를 정확히 복원하는가
  4. code_challenge가 Base64URL 규칙을 지키는가
  5. code가 재사용되지 않았는가(중복 콜백/재시도)
  6. code TTL 내에 교환하는가(로그인 지연/MFA 포함)
  7. client_id/issuer/도메인이 환경별로 섞이지 않았는가
  8. 토큰 요청이 폼 인코딩으로 전송되는가
  9. 시간 동기화가 깨진 노드가 없는가

마무리: invalid_grant를 “PKCE 문제”로만 단정하지 않기

PKCE를 쓰면 자연스럽게 code_verifier만 의심하게 되지만, 실제로는 redirect_uri 불일치, 코드 재사용, 환경 섞임 같은 운영 이슈가 더 빈번하게 원인이 되기도 합니다. 특히 “간헐적 발생”이라면 5번(중복 처리)과 9번(클럭 스큐)을 우선 의심하는 것이 경험적으로 효율적입니다.

장애 원인 분석을 문서화해두면 다음 번에는 훨씬 빨리 복구할 수 있습니다. 비슷한 방식으로 원인을 리스트업해 진단 시간을 줄이는 글도 참고할 만합니다: AWS S3 403 AccessDenied - 버킷정책·SCP 10분 진단