- Published on
OAuth 2.0 PKCE invalid_grant 8가지 원인·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 invalid_grant 를 반환하면 대개 “코드 교환 단계에서 뭔가가 어긋났다”는 뜻입니다. 특히 OAuth 2.0 Authorization Code Flow + PKCE에서는 code_verifier 와 code_challenge 의 결합, 리다이렉트 URI 정합성, 그리고 authorization_code 의 수명/단일 사용 같은 제약이 강해져서 작은 불일치도 즉시 invalid_grant 로 터집니다.
이 글은 PKCE 환경에서 실제로 가장 자주 만나는 invalid_grant 8가지 원인을 증상과 원리, 해결 방법, 재발 방지 체크까지 한 번에 정리합니다.
참고로 로그인 리다이렉트가 꼬여 302가 무한 반복되는 케이스는 토큰 교환 이전 단계에서 터지는 경우가 많습니다. 그 패턴은 Keycloak OAuth2 로그인 루프(무한 302) 해결 가이드 도 함께 보면 원인 분리가 빨라집니다.
PKCE 토큰 교환의 정상 흐름(짧게 복습)
정상적인 PKCE 흐름은 다음 2단계가 핵심입니다.
- 인가 요청:
code_challenge를 포함해/authorize로 이동 - 토큰 교환: 받은
code와 원래의code_verifier를/token에 제출
인가 요청 예시(쿼리 파라미터):
response_type=codeclient_id=...redirect_uri=...code_challenge=...code_challenge_method=S256state=...
토큰 요청 예시(폼):
grant_type=authorization_codecode=...redirect_uri=...client_id=...code_verifier=...
이 중 하나라도 “인가 요청 때의 값”과 “토큰 교환 때의 값”이 다르면 서버는 보통 invalid_grant 로 응답합니다.
디버깅 준비: 서버 로그와 요청을 그대로 남기기
invalid_grant 는 클라이언트에 돌아오는 에러 문자열만으로는 원인 특정이 어렵습니다. 아래를 먼저 준비하면 해결 속도가 크게 올라갑니다.
- IdP 로그 레벨 상향(Keycloak, Auth0, Cognito 등)
/authorize요청 URL 전체(민감정보 제외)/token요청 바디 전체(민감정보 마스킹)- 요청 시각과 서버 시각(시간 동기)
state와code_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_verifier 와 code_challenge 가 스펙에 맞게 생성되는지부터 검증할 수 있습니다.
원인 1) redirect_uri 불일치(가장 흔함)
증상
- 인가 요청은 정상 로그인 후 콜백까지 오는데, 토큰 교환에서
invalid_grant - IdP 로그에
redirect_uri mismatch류 메시지
원리
OAuth 서버는 인가 코드에 “어떤 redirect_uri 로 발급했는지”를 묶어둡니다. 토큰 교환 시 제출한 redirect_uri 가 문자열로 완전히 동일해야 합니다.
다음도 모두 “불일치”로 봅니다.
- 스킴
http와https차이 - 호스트
localhost와127.0.0.1차이 - 포트
3000누락/추가 - 경로의 슬래시 유무(예:
/callbackvs/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_verifier와code_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는 반드시 검증하고, 실패 시 토큰 교환을 절대 하지 않기state와code_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 를 빠르게 좁힐 수 있습니다.
/authorize와/token의redirect_uri가 완전 동일한가code_verifier를 “인가 요청 1회 생성”하고 “콜백까지 동일 값 유지”하는가S256계산이 Base64URL 규칙을 정확히 따르는가- 동일
code로 토큰 교환을 두 번 시도하지 않는가 - 코드 만료 시간을 넘기지 않는가, 서버 시간이 동기화되어 있는가
- 클라이언트가 public인지 confidential인지, 토큰 요청 인증 방식이 맞는가
state를 검증하고,state와code_verifier를 1:1로 묶었는가- 운영 프록시/WAF가 바디를 변형/차단하지 않는가(특히
code_verifier)
Next.js에서 흔한 구현 패턴(예시)
Next.js에서 “서버가 토큰 교환을 담당”하도록 하면 code_verifier 보관과 CORS, 시크릿 관리가 쉬워집니다. 예시는 다음 형태가 안전합니다.
/auth/start에서state,code_verifier생성 후 서버 세션(또는 Redis)에 저장- IdP로 리다이렉트
/auth/callback에서state로code_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_uri 와 code_verifier 보관/복원, S256 계산 규칙, 코드 재사용 방지만 제대로 잡아도 재현 빈도가 급격히 줄어듭니다.
운영에서만 재현된다면 프록시/WAF 변형, 스티키 세션 부재, 시간 동기화 문제를 우선 의심하세요. 그리고 가능하면 토큰 교환을 서버로 옮겨(또는 BFF 패턴) PKCE 상태를 안정적으로 관리하는 것이 장기적으로 가장 비용이 적습니다.