- Published on
OAuth 2.1 PKCE invalid_grant 해결 12가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 로그인이나 사내 SSO를 붙이다 보면, 인증 서버의 토큰 엔드포인트에서 invalid_grant 한 줄로 모든 것이 끝나는 순간을 자주 만납니다. 특히 OAuth 2.1에서 PKCE를 기본으로 쓰는 흐름에서는, Authorization Code 자체는 정상 발급되었는데 토큰 교환에서 실패하는 케이스가 많습니다.
invalid_grant는 표준적으로 “그랜트가 유효하지 않다”는 매우 넓은 의미라서, 서버 구현체마다 원인 메시지를 숨기거나 로그로만 남기기도 합니다. 이 글은 PKCE 기반 Authorization Code 흐름에서 invalid_grant가 나는 대표 원인 12가지를, 실무에서 바로 점검할 수 있게 체크리스트 형태로 정리합니다.
관련해서 인증 실패가 토큰 검증 단계(JWKS 캐시, 키 회전)에서 터지는 경우도 많습니다. 그 케이스는 별도 글인 Auth0+React JWT 검증 실패 - JWKS 캐시·키회전 대응도 함께 참고하면 디버깅 시간이 줄어듭니다.
전제: PKCE 토큰 교환 요청이 정확히 어떻게 생겼나
Authorization Code + PKCE에서 토큰 교환은 대략 아래 형태입니다.
curl -X POST "https://auth.example.com/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "client_id=client_123" \
-d "code=SplxlOBeZQQYbYS6WxSbIA" \
-d "redirect_uri=https://app.example.com/callback" \
-d "code_verifier=Zg3v...very-long-random...p9"
여기서 하나라도 서버가 기대하는 값과 다르면 invalid_grant로 뭉뚱그려 떨어질 수 있습니다.
1) redirect_uri가 Authorization 요청과 1바이트라도 다름
가장 흔한 원인입니다. Authorization 요청에서 사용한 redirect_uri와 토큰 교환 요청의 redirect_uri는 “완전 일치”를 요구하는 서버가 많습니다.
체크 포인트
- 스킴
httpvshttps - 호스트
app.example.comvswww.app.example.com - 포트
:3000포함 여부 - 경로 트레일링 슬래시
/callbackvs/callback/ - 쿼리 포함 여부
재현 예시
# Authorization 때는 /callback
# Token 교환 때는 /callback/ 로 보내면 일부 IdP에서 invalid_grant
-d "redirect_uri=https://app.example.com/callback/"
해결
- Authorization 요청에 사용한
redirect_uri를 그대로 저장해 두고 토큰 교환에 재사용 - 서버 등록된 redirect URI 목록과 완전 일치하도록 고정
2) Authorization Code를 두 번 사용함(재사용 또는 동시 요청)
Authorization Code는 1회성입니다. 프론트에서 토큰 교환을 두 번 시도하거나, 백엔드와 프론트가 동시에 교환하면 두 번째 요청은 invalid_grant가 납니다.
자주 생기는 패턴
- 콜백 페이지에서
useEffect가 두 번 실행(React Strict Mode 개발 환경) - 네트워크 재시도 로직이 멱등하지 않음
- 모바일에서 딥링크 처리 중 중복 호출
프론트 중복 방지 예시(React)
let exchanging = false;
export async function exchangeOnce(params: URLSearchParams) {
if (exchanging) return;
exchanging = true;
try {
// token exchange call
} finally {
exchanging = false;
}
}
3) Authorization Code 만료(짧은 TTL)
코드 TTL은 생각보다 짧습니다(30초~수분). 특히 모바일 네트워크, 앱 전환, 사용자 지연으로 토큰 교환이 늦어지면 만료로 invalid_grant가 납니다.
체크 포인트
- 콜백 수신부터 토큰 교환까지 걸린 시간 측정
- 서버 로그에서 code 발급 시각과 교환 시각 비교
해결
- 콜백을 받자마자 서버에서 즉시 교환(백엔드 주도)
- 사용자 상호작용 이후 교환 같은 구조 피하기
4) code_verifier 길이/문자셋 규격 위반
PKCE code_verifier는 RFC 7636에서 길이 43~128, 문자셋이 제한됩니다. 구현체가 엄격하면 규격 위반을 invalid_grant로 처리합니다.
Node.js에서 안전한 생성 예시
import crypto from "crypto";
export function generateCodeVerifier() {
// 32 bytes -> base64url로 대략 43자 이상
return crypto.randomBytes(32).toString("base64url");
}
체크 포인트
- 길이가 43 미만이거나 128 초과
+,/,=같은 base64 문자가 포함(서버가 base64url 기대)
5) code_challenge_method 불일치 또는 누락
Authorization 요청에서 code_challenge_method=S256로 보냈는데, 서버가 plain으로 저장했거나 클라이언트가 실수로 plain을 보내는 경우가 있습니다.
Authorization 요청 예시
https://auth.example.com/authorize?response_type=code&client_id=client_123&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&code_challenge=...&code_challenge_method=S256
해결
- 가능하면 항상
S256사용 - IdP 설정에서 PKCE 강제 옵션이 있는지 확인
6) code_challenge 계산이 base64url이 아니라 base64
S256 방식은 SHA-256(code_verifier)를 base64url로 인코딩해야 합니다. 여기서 base64와 base64url을 혼동하면 서버 검증이 실패해 invalid_grant가 납니다.
정확한 S256 계산 예시(Node.js)
import crypto from "crypto";
export function toCodeChallenge(verifier) {
const hash = crypto.createHash("sha256").update(verifier).digest();
// base64url 인코딩이 핵심
return Buffer.from(hash).toString("base64url");
}
체크 포인트
- 결과에
+,/,=가 남아 있으면 base64일 가능성
7) 토큰 교환 요청의 Content-Type 또는 바디 인코딩 오류
토큰 엔드포인트는 대개 application/x-www-form-urlencoded를 기대합니다. JSON으로 보내거나, form 인코딩이 깨지면 서버가 invalid_grant 또는 invalid_request로 응답할 수 있습니다.
Axios 예시(올바른 form 인코딩)
import axios from "axios";
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: "client_123",
code: authCode,
redirect_uri: "https://app.example.com/callback",
code_verifier: verifier,
});
const res = await axios.post("https://auth.example.com/oauth/token", body, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
체크 포인트
- 프록시/게이트웨이가 바디를 변환하거나 잘라먹지 않는지
code_verifier에 특수문자가 있을 때 URL 인코딩이 깨지지 않는지
8) 클라이언트 타입 혼동: Public 클라이언트에 client_secret을 보내거나 반대
PKCE는 주로 Public 클라이언트(브라우저 SPA, 모바일)에서 사용합니다. 그런데 토큰 교환에서 client_secret을 보내면, 서버 정책에 따라 거부되거나 예상과 다른 클라이언트로 매칭되어 invalid_grant가 날 수 있습니다.
체크 포인트
- SPA인데
client_secret을 프론트에 넣고 있지 않은지(보안 사고) - Confidential 클라이언트인데 인증 방식이 누락되지 않았는지
해결
- SPA는 PKCE +
client_id만으로 교환(서버 설정 확인) - Confidential 클라이언트는
client_secret_basic또는client_secret_post를 서버 요구대로 맞춤
9) Authorization 요청의 client_id와 토큰 교환의 client_id 불일치
멀티 테넌트, 환경 분리(dev/stg/prod)에서 자주 발생합니다.
체크 포인트
- Authorization URL을 만드는 프론트 설정과 토큰 교환을 하는 백엔드 설정이 같은지
- 모바일 앱 빌드별로 client 설정이 섞이지 않았는지
해결
- 콜백 요청에 포함된 issuer 또는 환경 정보를 기준으로, 같은 환경의 client 설정을 선택
10) Authorization Server의 시간 오차 또는 클러스터 노드 간 세션 불일치
일부 IdP는 Authorization Code를 노드 로컬 스토리지나 세션에 저장합니다. 로드밸런서 뒤에서 세션 스티키가 없거나, 노드 간 공유가 안 되면 “발급한 노드”와 “교환을 처리한 노드”가 달라져 invalid_grant가 발생할 수 있습니다.
체크 포인트
- Authorization 엔드포인트와 Token 엔드포인트가 같은 클러스터인지
- 스티키 세션 필요 여부
- 노드 간 공유 저장소(예: Redis) 구성 여부
- NTP 동기화 상태
해결
- 코드 저장을 중앙 저장소로
- 스티키 세션 적용(가능하면 근본 해결은 공유 저장)
네트워크 타임아웃이나 중간 장애로 재시도가 꼬이며 증상이 확대되는 경우가 많습니다. 인프라 관점의 타임아웃/재시도 설계는 EKS Pod→ElastiCache Redis 10분 타임아웃 진단법 같은 글의 접근(관측 지점 분리, 타임라인 복원)이 그대로 도움이 됩니다.
11) Authorization Code가 다른 redirect_uri로 발급됨(프록시 환경에서 원본 URL 혼동)
리버스 프록시(ALB, Nginx) 뒤에서 X-Forwarded-Proto, X-Forwarded-Host 처리가 잘못되면, 서버가 인지하는 외부 URL과 실제 브라우저 URL이 달라집니다. 그 결과 Authorization 요청은 A URL로 나갔는데, 서버는 B URL로 기록하거나, 토큰 교환에서 redirect mismatch로 invalid_grant가 납니다.
체크 포인트
- 앱이 생성하는 redirect URI가 내부 도메인/내부 스킴을 쓰고 있지 않은지
- 프록시가
X-Forwarded-*를 전달하는지 - 프레임워크의
trust proxy설정(Express 등)
Express 예시
import express from "express";
const app = express();
app.set("trust proxy", true);
12) Authorization 요청 파라미터가 서버 정책에 의해 정규화되거나 거부됨
일부 IdP는 Authorization 요청에서 특정 파라미터 조합을 강제합니다. 예를 들어
response_type=code만 허용- 특정
scope가 빠지면 code는 나오지만 토큰 교환에서 실패 처리 audience또는 리소스 파라미터가 불일치
체크 포인트
- Authorization 요청과 Token 요청의 파라미터를 “원문 그대로” 로깅
- IdP 콘솔에서 앱 정책(Allowed scopes, audiences, redirect URI, PKCE required)을 재확인
해결
- 최소 파라미터로 시작해 하나씩 추가하며 문제 파라미터를 찾기
- 서버 로그에서 “정책 위반”이
invalid_grant로 매핑되는지 확인
디버깅을 빠르게 만드는 실전 로깅 템플릿
invalid_grant는 클라이언트와 서버 로그를 한 타임라인으로 묶어야 빨리 끝납니다. 아래 항목은 민감정보를 마스킹하되, 비교 가능한 형태로 남기는 것을 권장합니다.
- Authorization 요청 시각,
state(해시 처리),redirect_uri,code_challenge_method,code_challenge앞 8자 - 콜백 수신 시각,
code앞 8자,state일치 여부 - 토큰 교환 시각,
redirect_uri,client_id,code_verifier길이 - 토큰 엔드포인트 응답 전문(가능하면 서버 측 correlation id)
Node.js 예시(마스킹 로깅)
function mask(s, keep = 8) {
if (!s) return s;
return s.slice(0, keep) + "...";
}
console.log({
t: new Date().toISOString(),
client_id,
redirect_uri,
code: mask(code),
verifier_len: code_verifier?.length,
challenge: mask(code_challenge),
});
정리: invalid_grant는 “불일치”의 다른 이름
PKCE에서 invalid_grant는 결국 아래 3종 불일치로 수렴하는 경우가 많습니다.
- 값 불일치:
redirect_uri,client_id,code_verifier와code_challenge - 시점 불일치: code 만료, 중복 교환, 재시도/동시성
- 환경 불일치: 프록시로 인한 외부 URL 인지 오류, 클러스터 세션/스토리지 분리
위 12가지를 순서대로 체크하면, 대부분은 30분 안에 원인을 특정할 수 있습니다. 특히 redirect_uri 완전 일치와 code_verifier 생성 및 S256 계산(base64url)을 먼저 고정하면, PKCE 관련 invalid_grant의 절반 이상이 사라집니다.