- Published on
OAuth PKCE invalid_grant 7가지 원인과 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 로그인이나 사내 SSO를 붙이다 보면, Authorization Code를 받아왔는데도 토큰 교환 단계에서 invalid_grant 로 막히는 순간이 자주 옵니다. 특히 PKCE를 쓰는 SPA/모바일 앱에서는 코드가 “맞는 것처럼 보이는데” 서버가 거절하는 경우가 많습니다.
invalid_grant 는 말 그대로 “그랜트가 유효하지 않다”는 뭉뚱그린 오류라서, 실제 원인은 PKCE 검증 실패, redirect URI 불일치, 코드 재사용, 시간 동기화 문제 등 여러 갈래로 갈립니다. 이 글은 PKCE 기반 Authorization Code Flow에서 invalid_grant 를 만드는 7가지 대표 원인을 증상과 확인 포인트, 해결까지 한 번에 정리한 실전 체크리스트입니다.
추가로 PKCE 점검을 더 촘촘히 하고 싶다면 내부 글인 OAuth2 PKCE에서 invalid_grant 뜰 때 7가지 점검도 함께 참고하면 좋습니다.
먼저 확인할 것: 어떤 단계에서 invalid_grant 인가
대부분은 토큰 엔드포인트 호출에서 발생합니다.
/authorize단계: 보통invalid_request,unauthorized_client가 더 흔함/token단계:invalid_grant가 가장 흔함 (코드, PKCE, redirect URI, 재사용 등)
아래 예시는 전형적인 토큰 교환 요청입니다.
curl -sS -X POST "https://idp.example.com/oauth2/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "client_id=spa-client" \
-d "code=SplxlOBeZQQYbYS6WxSbIA" \
-d "redirect_uri=https://app.example.com/callback" \
-d "code_verifier=3b2f..."
서버 응답은 대개 이렇게 옵니다.
{
"error": "invalid_grant",
"error_description": "Code verifier mismatch"
}
문제는 많은 IdP가 error_description 을 빈 값으로 주거나, 로깅을 안 켜면 힌트가 부족하다는 점입니다. 그래서 원인을 “패턴”으로 잡는 게 중요합니다.
원인 1) code_verifier 가 원래 값과 다르다 (가장 흔함)
증상
/authorize는 성공/token에서invalid_grant- IdP에 따라
PKCE verification failed류의 설명이 붙기도 함
왜 발생하나
- 앱이
code_verifier를 메모리 변수에만 저장했다가 리다이렉트/새로고침으로 유실 - 멀티 탭/멀티 로그인 시도에서 verifier가 덮어쓰기 됨
- 모바일에서 WebView와 앱 저장소가 분리되어 verifier를 못 찾음
- 서버가
code_verifier를 트림하거나 인코딩을 변경
해결
code_verifier는 요청 시작 시점에 생성하고, 리다이렉트 이후에도 복원 가능한 저장소에 저장- SPA:
sessionStorage권장 (탭 단위 격리) - 네이티브: Keychain/Keystore 또는 안전한 앱 스토리지
- SPA:
- 로그인 시도마다
state를 키로 해서 verifier를 매핑 (덮어쓰기 방지)
예시: SPA에서 state 별로 verifier 저장
function saveVerifier(state: string, verifier: string) {
sessionStorage.setItem(`pkce.verifier.${state}`, verifier)
}
function loadVerifier(state: string) {
const v = sessionStorage.getItem(`pkce.verifier.${state}`)
if (!v) throw new Error('Missing PKCE verifier for state')
return v
}
그리고 콜백에서 state 를 먼저 읽고 verifier를 복원해 토큰 교환에 사용합니다.
원인 2) code_challenge_method 불일치 또는 S256 계산/인코딩 오류
증상
- verifier는 있는 것 같은데 계속 실패
- 어떤 환경에서는 되고, 어떤 환경에서는 안 됨 (특정 브라우저/런타임)
왜 발생하나
- IdP는
S256을 기대하는데 클라이언트가plain으로 보냄 S256계산 후 Base64 URL 인코딩을 잘못함+와/를-와_로 바꿔야 함- 패딩
=제거 필요
해결
/authorize요청에서code_challenge_method=S256를 명시- Base64 URL 인코딩을 “정확히” 구현
Node/브라우저 공통 개념 예시(의사 코드)
// 주의: 아래는 개념 예시이며, 런타임별로 base64url 구현이 다릅니다.
import { createHash, randomBytes } from 'crypto'
function base64url(buf: Buffer) {
return buf.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '')
}
export function createPkcePair() {
const verifier = base64url(randomBytes(32))
const challenge = base64url(createHash('sha256').update(verifier).digest())
return { verifier, challenge, method: 'S256' }
}
추가 팁: IdP가 plain 을 막는 경우가 많습니다. 운영 환경에서는 사실상 S256 고정이 안전합니다.
원인 3) redirect_uri 가 1바이트라도 다르다
증상
/authorize는 통과했는데/token에서invalid_grant- IdP 로그에
redirect_uri mismatch가 찍히는 경우도 있음
왜 발생하나
OAuth 2.0에서 Authorization Code는 특정 redirect URI에 바인딩됩니다. 토큰 교환 시 redirect_uri 가 최초 승인 요청과 “완전히 동일”해야 하는 IdP가 많습니다.
자주 틀리는 케이스
https://app.example.com/callback과https://app.example.com/callback/(슬래시)http와https- 포트 포함 여부 (
:443) - 쿼리스트링 포함 여부
해결
/authorize와/token에서 동일한redirect_uri문자열을 재사용- 환경별로 조립하지 말고 “등록된 값”을 상수로 관리
예시: 프론트/백엔드가 따로 조립하지 않게 설정으로 고정
OAUTH_REDIRECT_URI=https://app.example.com/callback
const redirectUri = process.env.OAUTH_REDIRECT_URI!
// authorize와 token 교환 모두 동일한 redirectUri 사용
원인 4) Authorization Code 재사용(중복 교환) 또는 레이스 컨디션
증상
- 첫 시도는 성공, 두 번째부터
invalid_grant - 또는 네트워크 재시도/중복 요청이 있을 때 간헐적으로 실패
왜 발생하나
Authorization Code는 일회용입니다. 다음 상황에서 재사용이 쉽게 발생합니다.
- 콜백 페이지에서 토큰 교환 API를 두 번 호출 (React Strict Mode, 이중 렌더)
- 서비스 워커/리트라이 로직이 POST를 재전송
- 사용자가 콜백 URL을 새로고침
- 백엔드와 프론트가 각각 토큰 교환을 시도
해결
- 콜백 처리에서 “한 번만” 교환되도록 가드
- 이미 교환한
code를 저장하고 재요청 차단 - 프론트는 콜백 처리 직후 URL에서
code파라미터를 제거
React에서 이중 실행 방지 예시
let exchanging = false
export async function exchangeOnce(params: URLSearchParams) {
if (exchanging) return
exchanging = true
const code = params.get('code')
if (!code) throw new Error('Missing code')
// 토큰 교환 호출
}
서버 사이드에서는 code 를 키로 짧은 TTL 락을 걸어도 좋습니다.
원인 5) 코드 만료, 서버 시간 불일치, 지연으로 인한 타임아웃
증상
- 로그인 UI에서 오래 머물렀다가 진행하면 실패
- 모바일 네트워크에서만 실패율이 높음
- IdP가
code expired류를invalid_grant로 뭉개서 반환
왜 발생하나
Authorization Code는 보통 수십 초에서 수분 내 만료됩니다. 또한 인프라 레벨에서 시간 동기화가 깨지면(특히 컨테이너/VM의 NTP 문제) 토큰 검증 단계에서 예상치 못한 만료 판정이 날 수 있습니다.
해결
- 사용자가 오래 머무를 수 있는 UX라면, authorize 요청을 “마지막 순간에” 시작
- 서버/노드의 시간 동기화(NTP) 확인
- 프록시/게이트웨이로 인해
/token요청이 지연되지 않는지 APM으로 확인
시간 동기화 이슈는 다른 네트워크 진단과 함께 접근해야 할 때가 많습니다. 인프라 관점의 진단 습관은 Azure VM IMDS 169.254.169.254 접근 실패 원인·해결처럼 “원인 후보를 계층별로 쪼개는 방식”이 도움이 됩니다.
원인 6) client_id/앱 타입 혼동: SPA인데 confidential client처럼 동작
증상
- 어떤 환경에서는 되는데 운영에서만 실패
client_secret을 보내면 오히려 실패하거나, 보내지 않으면 실패
왜 발생하나
PKCE는 주로 public client(SPA/모바일)에서 쓰지만, IdP 설정이 다음처럼 꼬이면 invalid_grant 로 나타날 수 있습니다.
- 클라이언트가 confidential로 등록되어
client_secret을 요구 - 반대로 public로 등록했는데 서버가
client_secret을 보내거나 Basic Auth 헤더를 붙임 - 같은
client_id를 여러 앱이 공유하면서 redirect URI/PKCE 정책이 충돌
해결
- IdP 콘솔에서 해당 앱이 public인지 confidential인지 명확히 정리
- SPA는 원칙적으로
client_secret을 보관하면 안 됨 - 백엔드가 토큰 교환을 대행하는 패턴(BFF)을 쓸 거면, 그때는 confidential로 두고 PKCE를 병행할지 정책을 명확히
토큰 교환 요청이 어떤 형태로 나가는지 예시를 분리해두면 혼동이 줄어듭니다.
# public client (SPA) 예시: secret 없이 PKCE로 교환
-d "client_id=spa-client" \
-d "code_verifier=..."
# confidential client (server) 예시: Basic Auth 또는 client_secret 사용
-H "Authorization: Basic ..." \
-d "client_id=server-client" \
-d "client_secret=..."
원인 7) 프록시/로드밸런서/WAF가 요청 바디를 변형하거나 차단
증상
- 로컬에서 직접 IdP 호출하면 성공
- 운영에서만
invalid_grant - 특정 길이 이상의
code_verifier에서만 실패
왜 발생하나
일부 프록시/보안 장비가 다음을 건드리면 PKCE 검증이 깨질 수 있습니다.
application/x-www-form-urlencoded바디를 재인코딩- 특수문자(예:
-,_,.)를 정규화하거나 필터링 - 바디 길이 제한으로
code_verifier일부가 잘림 - 캐싱/재시도로 동일 요청이 중복 전송
해결
/token엔드포인트로 가는 경로에서 WAF 룰 예외 또는 바디 변형 비활성화- 게이트웨이에서 요청/응답 원문 로깅(민감정보 마스킹 필수)
code_verifier길이를 RFC 권장 범위(43~128)로 유지
Nginx를 앞단에 두는 경우, 바디 사이즈 제한도 확인합니다.
client_max_body_size 1m;
또한 서버에서 수신한 code_verifier 를 그대로 로깅하면 보안 사고가 될 수 있으니, 해시로만 남기는 식이 안전합니다.
import { createHash } from 'crypto'
function sha256Hex(s: string) {
return createHash('sha256').update(s).digest('hex')
}
logger.info({
codeVerifierHash: sha256Hex(codeVerifier),
codeLength: code?.length,
redirectUri
}, 'token exchange attempt')
빠른 진단 체크리스트(10분 컷)
아래 순서대로 보면 대부분은 빠르게 좁혀집니다.
/authorize에서 생성한state와code_verifier가 콜백에서 그대로 복원되는가code_challenge_method가S256이고, Base64 URL 인코딩이 정확한가/authorize와/token의redirect_uri문자열이 완전히 동일한가- 같은
code로/token을 두 번 치지 않는가(새로고침/이중 호출/재시도) - 코드 발급부터 교환까지 시간이 너무 길지 않은가(만료)
- 클라이언트 타입(public/confidential)과
client_secret사용이 설정과 일치하는가 - 프록시/WAF가 바디를 변형/차단하지 않는가(특히 운영에서만 실패할 때)
이런 “증상 기반 체크리스트” 접근은 다른 장애에서도 유효합니다. 예를 들어 파이썬 환경 꼬임을 빠르게 분해하는 방식은 pip install은 성공인데 실행하면 ModuleNotFoundError가 뜰 때 venv poetry conda 혼용으로 꼬인 인터프리터와 site-packages를 10분 만에 진단하고 확실히 고치는 체크리스트처럼, 원인을 계층화해 확인하는 게 핵심입니다.
마무리: invalid_grant 는 “PKCE만”의 문제가 아니다
PKCE를 쓰면 보안은 강해지지만, 그만큼 “문자열 동일성”과 “일회성” 제약이 늘어납니다. invalid_grant 가 떴을 때는 PKCE 계산만 의심하기보다,
- redirect URI 바인딩
- 재사용/레이스
- 만료/시간
- 클라이언트 타입
- 프록시 변형
까지 함께 보아야 가장 빨리 해결됩니다.
운영에서 재현이 어렵다면, 토큰 교환 요청의 핵심 파라미터(길이, 해시, redirect URI, state 매핑 여부)만 안전하게 로깅해도 원인 추적 속도가 크게 올라갑니다.