- Published on
OAuth2 PKCE에서 invalid_grant 뜨는 9가지 원인
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 로그인이나 사내 SSO를 붙이다 보면, Authorization Code + PKCE 플로우에서 토큰 교환 단계에 invalid_grant가 터지는 순간이 있습니다. 문제는 이 에러가 “그랜트가 유효하지 않다”는 뭉뚱그린 메시지라서, 실제 원인(코드 만료, 리다이렉트 불일치, code_verifier 불일치 등)을 로그로 좁혀가야 한다는 점입니다.
이 글은 PKCE 환경에서 invalid_grant가 발생하는 대표적인 9가지 케이스를, 어디를 확인해야 하는지와 어떻게 고치는지 중심으로 정리합니다. Auth0를 쓰는 경우라면 더 구체적인 체크리스트는 아래 글도 함께 참고하세요.
PKCE에서 invalid_grant가 주로 터지는 지점
PKCE 플로우는 대략 다음 순서로 진행됩니다.
- 클라이언트가
code_verifier(랜덤 문자열) 생성 code_challenge = BASE64URL(SHA256(code_verifier))계산 후/authorize요청- 인증 서버가
authorization_code발급 - 클라이언트가
/token에code+code_verifier로 교환
invalid_grant는 보통 4번, 즉 /token 요청에서 발생합니다. 따라서 디버깅은 “/authorize 때 보낸 값”과 “/token 때 보낸 값”의 일치성을 검증하는 방향으로 진행하는 게 빠릅니다.
1) redirect_uri 불일치 (가장 흔함)
증상
/authorize는 정상적으로 동작하고 콜백 URL로code가 돌아오지만/token에서invalid_grant
원인
OAuth2 스펙상 authorization code는 발급 시점의 redirect_uri와 토큰 교환 시점의 redirect_uri가 완전히 동일해야 합니다.
다음 차이도 불일치로 처리될 수 있습니다.
httpvshttps- trailing slash 유무:
https://app.example.com/callbackvshttps://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_challenge는 Base64가 아니라 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_uricode_challenge_methodcode_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라면 케이스별로 더 구체적인 로그 포인트와 대처법을 정리한 글도 함께 보면 문제를 더 빨리 좁힐 수 있습니다.