Published on

OAuth PKCE invalid_grant 7가지 원인과 해결법

Authors

서드파티 로그인이나 사내 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 또는 안전한 앱 스토리지
  • 로그인 시도마다 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/callbackhttps://app.example.com/callback/ (슬래시)
  • httphttps
  • 포트 포함 여부 (: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분 컷)

아래 순서대로 보면 대부분은 빠르게 좁혀집니다.

  1. /authorize 에서 생성한 statecode_verifier 가 콜백에서 그대로 복원되는가
  2. code_challenge_methodS256 이고, Base64 URL 인코딩이 정확한가
  3. /authorize/tokenredirect_uri 문자열이 완전히 동일한가
  4. 같은 code/token 을 두 번 치지 않는가(새로고침/이중 호출/재시도)
  5. 코드 발급부터 교환까지 시간이 너무 길지 않은가(만료)
  6. 클라이언트 타입(public/confidential)과 client_secret 사용이 설정과 일치하는가
  7. 프록시/WAF가 바디를 변형/차단하지 않는가(특히 운영에서만 실패할 때)

이런 “증상 기반 체크리스트” 접근은 다른 장애에서도 유효합니다. 예를 들어 파이썬 환경 꼬임을 빠르게 분해하는 방식은 pip install은 성공인데 실행하면 ModuleNotFoundError가 뜰 때 venv poetry conda 혼용으로 꼬인 인터프리터와 site-packages를 10분 만에 진단하고 확실히 고치는 체크리스트처럼, 원인을 계층화해 확인하는 게 핵심입니다.

마무리: invalid_grant 는 “PKCE만”의 문제가 아니다

PKCE를 쓰면 보안은 강해지지만, 그만큼 “문자열 동일성”과 “일회성” 제약이 늘어납니다. invalid_grant 가 떴을 때는 PKCE 계산만 의심하기보다,

  • redirect URI 바인딩
  • 재사용/레이스
  • 만료/시간
  • 클라이언트 타입
  • 프록시 변형

까지 함께 보아야 가장 빨리 해결됩니다.

운영에서 재현이 어렵다면, 토큰 교환 요청의 핵심 파라미터(길이, 해시, redirect URI, state 매핑 여부)만 안전하게 로깅해도 원인 추적 속도가 크게 올라갑니다.