Published on

OAuth 2.0 PKCE invalid_grant 8가지 원인·해결

Authors

서버가 invalid_grant 를 반환하면 대개 “코드 교환 단계에서 뭔가가 어긋났다”는 뜻입니다. 특히 OAuth 2.0 Authorization Code Flow + PKCE에서는 code_verifiercode_challenge 의 결합, 리다이렉트 URI 정합성, 그리고 authorization_code 의 수명/단일 사용 같은 제약이 강해져서 작은 불일치도 즉시 invalid_grant 로 터집니다.

이 글은 PKCE 환경에서 실제로 가장 자주 만나는 invalid_grant 8가지 원인을 증상원리, 해결 방법, 재발 방지 체크까지 한 번에 정리합니다.

참고로 로그인 리다이렉트가 꼬여 302가 무한 반복되는 케이스는 토큰 교환 이전 단계에서 터지는 경우가 많습니다. 그 패턴은 Keycloak OAuth2 로그인 루프(무한 302) 해결 가이드 도 함께 보면 원인 분리가 빨라집니다.

PKCE 토큰 교환의 정상 흐름(짧게 복습)

정상적인 PKCE 흐름은 다음 2단계가 핵심입니다.

  1. 인가 요청: code_challenge 를 포함해 /authorize 로 이동
  2. 토큰 교환: 받은 code 와 원래의 code_verifier/token 에 제출

인가 요청 예시(쿼리 파라미터):

  • response_type=code
  • client_id=...
  • redirect_uri=...
  • code_challenge=...
  • code_challenge_method=S256
  • state=...

토큰 요청 예시(폼):

  • grant_type=authorization_code
  • code=...
  • redirect_uri=...
  • client_id=...
  • code_verifier=...

이 중 하나라도 “인가 요청 때의 값”과 “토큰 교환 때의 값”이 다르면 서버는 보통 invalid_grant 로 응답합니다.

디버깅 준비: 서버 로그와 요청을 그대로 남기기

invalid_grant 는 클라이언트에 돌아오는 에러 문자열만으로는 원인 특정이 어렵습니다. 아래를 먼저 준비하면 해결 속도가 크게 올라갑니다.

  • IdP 로그 레벨 상향(Keycloak, Auth0, Cognito 등)
  • /authorize 요청 URL 전체(민감정보 제외)
  • /token 요청 바디 전체(민감정보 마스킹)
  • 요청 시각과 서버 시각(시간 동기)
  • statecode_verifier 를 저장/복원하는 방식

Node.js에서 토큰 요청을 재현하는 최소 예시는 다음과 같습니다.

import crypto from "crypto";

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

const codeVerifier = base64url(crypto.randomBytes(32));
const codeChallenge = base64url(
  crypto.createHash("sha256").update(codeVerifier).digest()
);

console.log({ codeVerifierLength: codeVerifier.length, codeChallenge });

// token 교환 예시(fetch)
const params = new URLSearchParams({
  grant_type: "authorization_code",
  client_id: process.env.CLIENT_ID,
  redirect_uri: "https://app.example.com/callback",
  code: process.env.AUTH_CODE,
  code_verifier: codeVerifier,
});

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

console.log(res.status, await res.text());

이 코드로 내가 만든 code_verifiercode_challenge 가 스펙에 맞게 생성되는지부터 검증할 수 있습니다.

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

증상

  • 인가 요청은 정상 로그인 후 콜백까지 오는데, 토큰 교환에서 invalid_grant
  • IdP 로그에 redirect_uri mismatch 류 메시지

원리

OAuth 서버는 인가 코드에 “어떤 redirect_uri 로 발급했는지”를 묶어둡니다. 토큰 교환 시 제출한 redirect_uri문자열로 완전히 동일해야 합니다.

다음도 모두 “불일치”로 봅니다.

  • 스킴 httphttps 차이
  • 호스트 localhost127.0.0.1 차이
  • 포트 3000 누락/추가
  • 경로의 슬래시 유무(예: /callback vs /callback/)
  • 쿼리스트링 포함 여부

해결

  • /authorize/token 에 동일한 redirect_uri 를 사용
  • IdP 클라이언트 설정의 허용 리다이렉트 URI 목록에 정확히 등록
  • 프록시/로드밸런서 환경이면 외부 URL 기준으로 고정

Nginx나 ALB 뒤에서 스킴이 바뀌는 경우, 앱이 내부적으로 http 로 인식해 redirect_uri 를 잘못 만들기도 합니다. 이때는 X-Forwarded-Proto 를 신뢰하도록 프레임워크 설정을 조정하세요.

원인 2) code_verifier 가 바뀌거나 유실됨(세션/스토리지 문제)

증상

  • 첫 시도는 되기도 하고, 새로고침/뒤로가기/다른 탭에서 자주 실패
  • 모바일 Safari, 인앱 브라우저에서 재현률이 높음

원리

PKCE는 “인가 요청 때 만든 code_verifier 를 토큰 교환 때 그대로 제출”해야 합니다. 그런데 다음 이슈로 code_verifier 가 바뀌거나 사라집니다.

  • 서버 사이드 세션에 저장했는데, 콜백이 다른 인스턴스로 라우팅됨(스티키 세션 없음)
  • 브라우저 스토리지에 저장했는데, 리다이렉트 과정에서 스토리지 접근 정책/격리로 초기화
  • 콜백 처리 전에 앱이 리렌더/재마운트되며 새 code_verifier 를 생성

React/Next.js에서는 렌더링 타이밍 문제로 상태가 초기화되는 경우도 있습니다. UI 레벨 문제 디버깅 감각은 React 렌더링 폭주? 리렌더 원인 추적 실전 가이드 도 도움이 됩니다.

해결

  • code_verifier인가 요청 직전에 1회 생성하고, 콜백까지 안정적으로 유지
  • SPA라면 sessionStorage 를 우선 고려(탭 단위 격리)
  • 서버라면 세션 스토어를 Redis 등 공유 저장소로 두거나 스티키 세션 적용

브라우저 저장 예시:

// authorize 직전
sessionStorage.setItem("pkce_verifier", codeVerifier);

// callback 처리 시
const verifier = sessionStorage.getItem("pkce_verifier");
if (!verifier) throw new Error("PKCE verifier missing");

원인 3) code_challenge_method 불일치 또는 S256 계산 오류

증상

  • 특정 플랫폼(모바일/특정 언어 SDK)에서만 실패
  • 서버 로그에 PKCE verification failed 류 메시지

원리

서버는 인가 요청의 code_challenge_method 에 따라 검증합니다.

  • S256: BASE64URL(SHA256(code_verifier))code_challenge 비교
  • plain: code_verifiercode_challenge 를 그대로 비교

자주 하는 실수:

  • Base64URL이 아닌 표준 Base64를 사용(특히 +, /, = 패딩)
  • UTF-8 인코딩 처리 실수
  • S256 로 요청해놓고 실제로는 plain 처럼 전송

해결

  • 가능하면 S256 고정
  • Base64URL 변환 규칙을 정확히 구현
  • code_verifier 길이가 스펙 범위인지 확인(일반적으로 43~128)

검증용으로 로컬에서 “내가 만든 값이 맞는지”를 재계산해 비교하는 테스트를 추가하세요.

원인 4) 인가 코드(code) 재사용(중복 교환)

증상

  • 첫 토큰 교환은 성공, 이후 동일 code 로 재시도하면 invalid_grant
  • 네트워크 재시도 로직이 있을 때 간헐적으로 발생

원리

Authorization Code는 1회용입니다. 다음 상황에서 재사용이 일어납니다.

  • 토큰 요청이 타임아웃 나서 클라이언트가 재시도했는데, 실제로는 서버가 이미 처리 완료
  • 콜백 엔드포인트가 중복 호출(사용자 더블 클릭, 브라우저 자동 재요청)
  • 프론트와 백엔드가 각각 토큰 교환을 시도

해결

  • 토큰 교환은 단일 컴포넌트/단일 서버 경로에서만 수행
  • 재시도는 멱등이 아니므로 매우 신중히 적용
  • 콜백 처리 시 code 를 “한 번 처리하면 폐기”하도록 앱 레벨 락/저장소를 둠

서버에서 단순 방어하는 예시(의사코드):

// code를 키로 한 1회 처리 락(예: Redis SETNX)
const lockKey = `oauth:code:${code}`;
const locked = await redis.set(lockKey, "1", { NX: true, EX: 60 });
if (!locked) throw new Error("Authorization code already processed");

원인 5) 인가 코드 만료 또는 서버 시간 불일치

증상

  • 로그인 후 잠시 기다렸다가 진행하면 실패
  • 특정 서버/리전에서만 실패

원리

Authorization Code는 수명이 매우 짧습니다(수십 초~수분). 또한 서버 간 시간이 어긋나면 “아직 유효한데 만료로 판단”하거나 반대로 “미래 시각”으로 처리될 수 있습니다.

해결

  • 사용자 플로우에서 콜백 후 즉시 토큰 교환
  • IdP와 앱 서버에 NTP 동기화 적용
  • 분산 환경에서 시간 오차 모니터링

원인 6) 클라이언트 인증 방식 오류(공개 클라이언트/기밀 클라이언트 혼동)

증상

  • 어떤 환경에서는 되는데, 배포 환경에서만 invalid_grant
  • Keycloak 등에서 클라이언트 타입 변경 후 시작

원리

PKCE는 주로 “공개 클라이언트”에서 쓰지만, IdP 설정에 따라 토큰 엔드포인트에서 다음을 요구할 수 있습니다.

  • client_secret 을 Basic Auth로 보내야 함
  • 혹은 공개 클라이언트로 설정되어 client_secret 을 보내면 오히려 거부

즉, 클라이언트 설정과 실제 요청의 인증 방식이 불일치하면 invalid_grant 또는 invalid_client 로 실패합니다(제품마다 코드가 다르게 나옵니다).

해결

  • IdP의 클라이언트 설정에서 public / confidential 을 명확히 확정
  • confidential이면 Authorization: Basic ... 또는 폼 파라미터 방식 중 하나로 통일

curl 예시(Basic Auth):

curl -sS -X POST "https://idp.example.com/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -u "client_id:client_secret" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=YOUR_CODE" \
  --data-urlencode "redirect_uri=https://app.example.com/callback" \
  --data-urlencode "code_verifier=YOUR_VERIFIER"

원인 7) state 검증 실패로 인한 잘못된 code 사용(간접 원인)

증상

  • 가끔 다른 사용자/다른 탭의 로그인 결과가 섞이는 듯한 현상
  • CSRF 방어 로직을 넣었는데도 토큰 교환이 실패

원리

엄밀히 말해 state 불일치 자체는 콜백 단계에서 차단해야 하지만, 구현이 어설프면 다음처럼 “잘못된 code 로 토큰 교환”을 시도하게 됩니다.

  • 탭 A에서 시작한 요청의 code_verifier 를 탭 B가 사용
  • 콜백에서 state 를 무시하고 code 만 보고 토큰 교환

이때 PKCE 검증이 실패해 invalid_grant 로 귀결됩니다.

해결

  • state 는 반드시 검증하고, 실패 시 토큰 교환을 절대 하지 않기
  • statecode_verifier 를 같은 저장 레코드로 묶어 관리

간단한 매핑 예시:

// authorize 직전
const state = crypto.randomUUID();
sessionStorage.setItem(`pkce:${state}`, codeVerifier);

// callback
const stateFromQuery = new URL(location.href).searchParams.get("state");
const verifier = sessionStorage.getItem(`pkce:${stateFromQuery}`);
if (!verifier) throw new Error("state mismatch or verifier missing");

원인 8) 프록시/게이트웨이/WAF가 요청 바디를 변형 또는 차단

증상

  • 로컬에서는 성공, 운영에서만 실패
  • 특정 길이 이상의 code_verifier 에서만 실패
  • 서버 로그에 요청 파라미터 누락(예: code_verifier 가 빈 값)

원리

토큰 요청은 보통 application/x-www-form-urlencoded 바디를 사용합니다. 그런데 중간 장비가 다음을 건드리면 PKCE 검증이 깨집니다.

  • + 를 공백으로 변환(인코딩 문제)
  • 특정 문자(-, _) 필터링
  • 바디 길이 제한으로 잘림
  • code_verifier 파라미터를 의심 파라미터로 보고 제거

해결

  • 토큰 요청은 반드시 URL 인코딩을 올바르게 적용
  • WAF 예외 규칙 또는 바디 크기 제한 상향
  • 게이트웨이에서 Content-Type 을 보존하는지 확인

Node.js에서 URLSearchParams 를 쓰면 인코딩을 비교적 안전하게 처리할 수 있습니다.

실전 체크리스트(재현부터 해결까지)

아래 순서대로 보면 대부분의 invalid_grant 를 빠르게 좁힐 수 있습니다.

  1. /authorize/tokenredirect_uri 가 완전 동일한가
  2. code_verifier 를 “인가 요청 1회 생성”하고 “콜백까지 동일 값 유지”하는가
  3. S256 계산이 Base64URL 규칙을 정확히 따르는가
  4. 동일 code 로 토큰 교환을 두 번 시도하지 않는가
  5. 코드 만료 시간을 넘기지 않는가, 서버 시간이 동기화되어 있는가
  6. 클라이언트가 public인지 confidential인지, 토큰 요청 인증 방식이 맞는가
  7. state 를 검증하고, statecode_verifier 를 1:1로 묶었는가
  8. 운영 프록시/WAF가 바디를 변형/차단하지 않는가(특히 code_verifier)

Next.js에서 흔한 구현 패턴(예시)

Next.js에서 “서버가 토큰 교환을 담당”하도록 하면 code_verifier 보관과 CORS, 시크릿 관리가 쉬워집니다. 예시는 다음 형태가 안전합니다.

  • /auth/start 에서 state, code_verifier 생성 후 서버 세션(또는 Redis)에 저장
  • IdP로 리다이렉트
  • /auth/callback 에서 statecode_verifier 를 조회하고 토큰 교환

간단한 서버 라우트 의사코드:

// /auth/start
const state = crypto.randomUUID();
const verifier = generateVerifier();
const challenge = toS256Challenge(verifier);

await redis.set(`pkce:${state}`, verifier, { EX: 300 });

const url = new URL("https://idp.example.com/authorize");
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", process.env.CLIENT_ID);
url.searchParams.set("redirect_uri", "https://app.example.com/auth/callback");
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("state", state);

return Response.redirect(url.toString());
// /auth/callback
const code = new URL(req.url).searchParams.get("code");
const state = new URL(req.url).searchParams.get("state");

const verifier = await redis.get(`pkce:${state}`);
if (!verifier) return new Response("invalid state", { status: 400 });

// 토큰 교환
const body = new URLSearchParams({
  grant_type: "authorization_code",
  client_id: process.env.CLIENT_ID,
  redirect_uri: "https://app.example.com/auth/callback",
  code,
  code_verifier: verifier,
});

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

const text = await tokenRes.text();
if (!tokenRes.ok) {
  // 운영에서는 민감정보 마스킹 후 로깅
  return new Response(text, { status: 502 });
}

return new Response(text, { status: 200 });

이 구조는 “탭/브라우저 저장소 이슈”와 “리렌더로 인한 verifier 재생성” 같은 클라이언트 변수를 크게 줄여줍니다.

마무리

PKCE에서 invalid_grant 는 대개 “검증 재료가 하나라도 바뀌었다”는 신호입니다. 특히 redirect_uricode_verifier 보관/복원, S256 계산 규칙, 코드 재사용 방지만 제대로 잡아도 재현 빈도가 급격히 줄어듭니다.

운영에서만 재현된다면 프록시/WAF 변형, 스티키 세션 부재, 시간 동기화 문제를 우선 의심하세요. 그리고 가능하면 토큰 교환을 서버로 옮겨(또는 BFF 패턴) PKCE 상태를 안정적으로 관리하는 것이 장기적으로 가장 비용이 적습니다.