- Published on
OAuth2 PKCE에서 invalid_grant 뜨는 9가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
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 failedinvalid redirect_uricode is expired/code already usedclient 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:/callbackvscom.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분 안에 원인 좁히기
아래 순서로 보면 대부분 빠르게 결론이 납니다.
- 토큰 요청을
curl로 재현 가능한가 - Authorization 요청과 토큰 요청의
redirect_uri문자열이 완전 동일한가 - 콜백의
state로code_verifier를 정확히 복원하는가 code_challenge가 Base64URL 규칙을 지키는가code가 재사용되지 않았는가(중복 콜백/재시도)codeTTL 내에 교환하는가(로그인 지연/MFA 포함)client_id/issuer/도메인이 환경별로 섞이지 않았는가- 토큰 요청이 폼 인코딩으로 전송되는가
- 시간 동기화가 깨진 노드가 없는가
마무리: invalid_grant를 “PKCE 문제”로만 단정하지 않기
PKCE를 쓰면 자연스럽게 code_verifier만 의심하게 되지만, 실제로는 redirect_uri 불일치, 코드 재사용, 환경 섞임 같은 운영 이슈가 더 빈번하게 원인이 되기도 합니다. 특히 “간헐적 발생”이라면 5번(중복 처리)과 9번(클럭 스큐)을 우선 의심하는 것이 경험적으로 효율적입니다.
장애 원인 분석을 문서화해두면 다음 번에는 훨씬 빨리 복구할 수 있습니다. 비슷한 방식으로 원인을 리스트업해 진단 시간을 줄이는 글도 참고할 만합니다: AWS S3 403 AccessDenied - 버킷정책·SCP 10분 진단