- Published on
OAuth PKCE invalid_grant 6가지 원인·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 로그인이나 모바일 로그인에서 PKCE를 붙였는데, token 엔드포인트에서 invalid_grant가 떨어지면 디버깅 난이도가 급격히 올라갑니다. 이유는 간단합니다. invalid_grant는 "그랜트가 유효하지 않다"라는 넓은 범주의 에러로, 실제 원인(코드 불일치, 리다이렉트 불일치, 만료, 재사용, verifier 인코딩 문제 등)을 서버가 뭉뚱그려 반환하는 경우가 많기 때문입니다.
이 글은 PKCE 기반 Authorization Code Flow에서 invalid_grant가 발생하는 대표 원인 6가지를 재현 포인트, 확인 로그, 해결 방법 중심으로 정리합니다. (특정 IdP 구현에 따라 메시지/상세 필드는 다를 수 있습니다.)
빠른 전제: PKCE가 붙은 Authorization Code Flow
흐름을 최소한으로 정리하면 아래와 같습니다.
- 클라이언트가
code_verifier(고엔트로피 문자열)를 생성 code_verifier로code_challenge를 생성 (보통S256)/authorize요청에code_challenge와code_challenge_method=S256를 포함- 리다이렉트로
authorization_code를 수령 /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_method가S256인데 실제로는 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 파라미터로만 허용하기도 하니, 문서/콘솔 설정을 최우선으로 맞추세요.
실전 디버깅 순서(추천)
- redirect_uri 완전 일치부터 확인 (가장 흔하고, 가장 빨리 잡힘)
- callback에서 받은
state로 verifier 매핑이 정확한지 확인 code_challenge를 로컬에서 재계산해 PKCE 인코딩 검증code가 만료되기 전에 바로 교환되는지 확인 (사용자 체류 시간, 앱 백그라운드)- 중복 교환 여부 확인 (재시도, 더블 클릭, 서버 중복 처리)
- 클라이언트 인증 방식(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_uri와 verifier 매핑부터 차근차근 좁혀가면, 대부분은 30분 내 원인에 도달할 수 있습니다.