- Published on
OAuth PKCE invalid_grant 해결 체크리스트
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 OAuth 또는 사내 IdP에서 PKCE(Proof Key for Code Exchange)를 붙이는 순간, 가장 흔하게 마주치는 에러가 invalid_grant입니다. 문제는 이 에러가 너무 포괄적이라서 “인가 코드가 틀렸다”부터 “리다이렉트 URI가 1글자 다르다”, “code_verifier 길이가 스펙 밖이다”까지 전부 같은 이름으로 떨어진다는 점입니다.
이 글은 PKCE 환경에서 invalid_grant를 빠르게 재현하고, 로그로 원인을 좁히고, 재발 방지까지 하는 체크리스트 형태로 정리합니다.
참고로, 장애를 체크리스트로 쪼개서 추적하는 방식은 아래 글들과 같은 결로 접근하면 효율이 좋습니다.
invalid_grant가 의미하는 것(공통 분모)
OAuth 2.0에서 invalid_grant는 대체로 토큰 엔드포인트에서 “제출한 grant(인가 코드, 리프레시 토큰 등)가 유효하지 않다”를 뜻합니다. PKCE에서는 특히 다음 케이스가 많습니다.
- 인가 코드(
code)가 이미 사용됨(재사용) - 인가 코드가 만료됨
redirect_uri불일치client_id불일치(또는 잘못된 client)- PKCE 검증 실패(
code_verifier또는code_challenge불일치) - 토큰 요청 형식 오류(파라미터 누락, 인코딩/전송 방식 오류)
이제부터는 “가장 흔하고, 확인이 쉬운 것부터” 순서대로 봅니다.
1) 인가 코드 재사용 여부(가장 흔함)
인가 코드 방식에서 code는 1회용입니다. 아래 상황에서 재사용이 쉽게 발생합니다.
- 프론트가 새로고침되며 콜백 URL을 다시 처리
- 모바일 WebView가 콜백을 두 번 로드
- 백엔드가 토큰 교환 재시도를 무조건 수행(네트워크 타임아웃 후 재시도)
- 멀티 인스턴스 환경에서 동일 콜백을 중복 처리(중복 요청)
체크
- 콜백 핸들러에서
code를 한 번만 처리하도록 멱등성 키를 둡니다. - 토큰 교환 실패 시 재시도는 “같은
code로 재시도”가 아니라, 다시 authorize부터 시작해야 합니다.
예시: 콜백 멱등 처리(서버)
// pseudo: callback handler
authCallback(req, res) {
const code = req.query.code;
const state = req.query.state;
// (1) state 검증 후
// (2) code를 저장소에 "사용 처리" (원자적)
const ok = redis.setnx(`oauth:code:used:${code}`, "1");
if (!ok) {
return res.status(409).send("code already used");
}
redis.expire(`oauth:code:used:${code}`, 300);
// 토큰 교환
}
2) 인가 코드 만료(짧은 TTL)
IdP마다 다르지만 인가 코드는 보통 수십 초에서 수 분 내 만료됩니다. 특히 모바일에서 앱 전환, 네트워크 지연, 사용자가 동의 화면에서 오래 머무는 경우 만료가 잦습니다.
체크
- IdP 설정에서 code TTL을 확인합니다.
- 콜백을 받은 즉시 토큰 교환을 수행합니다.
- 서버 시간이 크게 틀어져 있지 않은지(NTP) 확인합니다.
운영 팁
- 토큰 교환 요청의 시작 시각과
code발급 시각(가능하면)을 로그로 남겨 “만료”를 수치로 확인합니다.
3) redirect_uri 불일치(문자 단위로 동일해야 함)
토큰 교환 시 redirect_uri는 대개 authorize 요청 때와 완전히 동일해야 합니다. 다음 차이도 불일치로 처리될 수 있습니다.
httpvshttps- 도메인 대소문자
- 경로의 슬래시 유무(예:
.../callbackvs.../callback/) - 쿼리스트링 포함 여부
- 포트 포함 여부
체크
- authorize 요청의
redirect_uri와 token 요청의redirect_uri를 로그로 그대로 출력해서 비교합니다. - 환경별로 리다이렉트 URI가 바뀌는 경우(로컬, 스테이징, 프로덕션), IdP 등록값과 완벽히 매칭되는지 확인합니다.
예시: 토큰 요청 로그(민감정보 마스킹)
console.info("token_exchange", {
redirect_uri: redirectUri,
client_id: clientId,
grant_type: "authorization_code",
code_prefix: code?.slice(0, 6),
verifier_len: codeVerifier?.length,
});
4) PKCE 파라미터 생성/전달 오류
PKCE에서 핵심은 code_challenge와 code_verifier가 스펙대로 생성되고, 토큰 교환 시 동일한 code_verifier가 전달되는 것입니다.
4-1) code_challenge_method 불일치
요즘 IdP는 S256을 권장하거나 강제합니다. 그런데 클라이언트가 plain으로 보내거나, method를 누락하면 실패할 수 있습니다.
- authorize 요청:
code_challenge_method=S256+code_challenge=... - token 요청:
code_verifier=...
4-2) code_verifier 길이/문자셋 위반
code_verifier는 일반적으로 길이 43~128이고 URL-safe 문자셋을 사용해야 합니다. 구현에서 흔한 실수:
- 너무 짧은 랜덤 문자열
- base64 결과에
=패딩이 남음 +/같은 문자가 섞인 base64(표준 base64) 사용
4-3) base64url 인코딩 실수
S256은 대개 다음 순서입니다.
SHA-256(code_verifier)- 결과를 base64url 인코딩(패딩 제거)
예시: Node.js에서 올바른 PKCE 생성
import crypto from "crypto";
function base64url(buf) {
return buf.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
export function createPkcePair() {
const codeVerifier = base64url(crypto.randomBytes(32)); // 보통 43자 이상 확보
const challenge = base64url(
crypto.createHash("sha256").update(codeVerifier).digest()
);
return {
codeVerifier,
codeChallenge: challenge,
codeChallengeMethod: "S256",
};
}
체크
- authorize 요청에 보낸
code_challenge를 저장해두고, 서버(또는 디버그 도구)에서code_verifier로 다시 계산해 동일한 값이 나오는지 확인합니다. - 모바일/웹에서
code_verifier를 저장하는 위치가 안전하고 안정적인지 확인합니다(아래 5번 참고).
5) code_verifier 저장/복원 문제(특히 SPA, 모바일)
PKCE는 토큰 교환 시점에 code_verifier가 반드시 필요합니다. 그런데 다음 상황에서 code_verifier를 잃어버립니다.
- SPA에서 메모리 변수에만 저장했다가 리다이렉트로 초기화
- iOS/Android에서 프로세스가 죽었다가 복원되며 상태 유실
- 쿠키 SameSite 정책 때문에 세션이 유지되지 않음
체크
- 리다이렉트 전후로 유지되는 저장소를 사용합니다.
- 웹:
sessionStorage가 일반적(탭 단위) - 서버 연동: 서버 세션에
state키로 묶어서 저장
- 웹:
state를 키로code_verifier를 찾는 구조로 만듭니다.
예시: state 기반으로 verifier 매핑(서버)
# pseudo: before redirect to authorize
state = generate_state()
store.set(f"pkce:{state}", code_verifier, ttl=600)
# callback
code_verifier = store.get(f"pkce:{state}")
if not code_verifier:
raise Exception("missing code_verifier for state")
6) state 검증 실패를 invalid_grant로 오해하는 경우
일부 구현은 state 불일치나 누락을 자체적으로 처리하다가, 결과적으로 토큰 교환 단계에서 엉뚱한 code_verifier를 붙여 보내 invalid_grant가 납니다.
체크
- 콜백에서
state가 없거나 매칭되지 않으면 토큰 교환을 시도하지 말고 즉시 실패 처리합니다. state는 CSRF 방어뿐 아니라 “이 요청의 PKCE 재료를 찾는 키”로도 쓰입니다.
7) 토큰 엔드포인트 요청 형식 오류
IdP는 토큰 요청에서 다음을 엄격히 요구하는 경우가 많습니다.
Content-Type이application/x-www-form-urlencoded- 바디는 form 인코딩
- 파라미터 이름 정확히 일치(
code_verifier,redirect_uri등)
JSON으로 보내거나, 쿼리스트링으로 보내면 invalid_grant 또는 invalid_request로 뭉뚱그려 떨어질 수 있습니다.
예시: curl로 정상 토큰 교환
아래 예시는 S256 PKCE 기준입니다.
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" \
--data-urlencode "redirect_uri=https://app.example.com/oauth/callback" \
--data-urlencode "code_verifier=YOUR_CODE_VERIFIER"
체크
- 라이브러리가 자동으로 JSON 전송하지 않는지 확인합니다.
- 프록시/게이트웨이가 본문을 변형하지 않는지 확인합니다.
8) client_id 또는 앱 설정 불일치
환경별로 client가 여러 개면(로컬용, 스테이징용, 프로덕션용) 다음이 흔합니다.
- authorize는 A client로 했는데 token은 B client로 교환
- 앱 설정에서 PKCE 필수인데 클라이언트가 PKCE 없이 시도
- confidential client인데 secret을 요구하거나, 반대로 public client인데 secret을 보내서 거부
체크
- authorize 요청과 token 요청에서
client_id가 동일한지 로그로 검증합니다. - IdP 콘솔에서 해당 client가 PKCE를 요구하는지, redirect URI가 등록되어 있는지 확인합니다.
9) 여러 탭/동시 로그인으로 state와 verifier가 꼬임
사용자가 로그인 버튼을 연속 클릭하거나 탭을 여러 개 열면, 마지막에 생성한 state와 code_verifier가 저장소를 덮어써서 이전 콜백이 도착했을 때 매칭이 깨집니다.
체크
- 저장소 키를 “고정 키”로 두지 말고
state별로 분리합니다. - UI에서 로그인 시작 시 버튼을 비활성화하거나, 진행 중 세션을 표시합니다.
10) 네트워크/프록시 계층에서 파라미터 손실
WAF, API Gateway, 프록시가 특정 파라미터를 필터링하거나 길이 제한을 걸면 code_verifier가 잘리거나 누락될 수 있습니다.
체크
- 토큰 요청이 실제로 IdP에 어떤 바디로 전달되는지(프록시 이후) 확인합니다.
- 요청 바디 길이 제한, 특정 필드 차단 정책이 있는지 확인합니다.
11) 진단을 빠르게 만드는 로그/계측 포인트
invalid_grant는 IdP 로그 접근 권한이 없으면 더 어렵습니다. 그래서 애플리케이션 측에서 아래를 남겨두면 평균 해결 시간이 크게 줄어듭니다.
- authorize 요청 생성 시점
client_id,redirect_uri,scope,state(전체는 저장하되 로그는 prefix),code_challenge_method,code_challenge(prefix)
- 콜백 수신 시점
state매칭 성공 여부codeprefixcode_verifier존재 여부, 길이
- 토큰 교환 요청 시점
redirect_uri,client_id,grant_type,verifier_len
- 토큰 교환 응답
- HTTP status,
error,error_description(있다면)
- HTTP status,
민감정보는 원문 그대로 로그로 남기지 말고 prefix 또는 해시를 추천합니다.
예시: verifier 해시로 상관관계 추적
// pseudo
verifierHash := sha256hex(codeVerifier)[:12]
log.Info("pkce", "state", state[:8], "verifier_hash", verifierHash, "verifier_len", len(codeVerifier))
12) 재현용 최소 시나리오(문제 분리)
문제가 복잡할수록 “내 앱”을 의심하기 전에, 아래처럼 최소 재현으로 분리하면 원인이 선명해집니다.
- 브라우저에서 authorize URL을 직접 열어 콜백으로
code를 받는다 - 같은
redirect_uri, 같은client_id, 같은code_verifier로curl토큰 교환을 해본다 curl이 성공하면 앱 구현(저장/전달/인코딩) 문제일 확률이 높다curl도 실패하면 IdP 설정(redirect URI 등록, PKCE 정책, client 타입) 문제일 확률이 높다
최종 체크리스트(요약)
아래 항목을 위에서 아래로 순서대로 확인하면, 대부분의 invalid_grant는 30분 내에 원인을 특정할 수 있습니다.
code를 재사용하고 있지 않은가(콜백 중복 처리, 재시도)code가 만료되지 않았는가(지연, 사용자 체류, 서버 시간)- authorize와 token의
redirect_uri가 문자 단위로 동일한가 code_challenge_method가S256이고, 생성 로직이 base64url 규칙을 지키는가code_verifier길이와 문자셋이 스펙 범위인가- 리다이렉트 전후로
code_verifier를 안정적으로 저장/복원하는가 state검증 실패 시 토큰 교환을 시도하지 않는가- 토큰 요청이
application/x-www-form-urlencoded로 전송되는가 client_id가 authorize와 token에서 동일한가(환경 혼선)- 프록시/WAF가 파라미터를 누락/절단하지 않는가
PKCE의 invalid_grant는 “PKCE가 어렵다”기보다, 대개 요청 간 상태를 안정적으로 이어주는 설계와 완전 일치 문자열 비교(redirect URI), 그리고 정확한 인코딩에서 갈립니다. 위 체크리스트대로 로그를 정리해두면, 다음 번엔 같은 증상이 나와도 원인 파악이 훨씬 빨라집니다.