Published on

OAuth 2.0 PKCE invalid_grant 원인 7가지

Authors

서드파티 로그인이나 사내 SSO를 붙일 때 OAuth 2.0 Authorization Code + PKCE를 선택하면 보안은 좋아지지만, 운영에서 가장 자주 마주치는 에러가 invalid_grant입니다. 문제는 이 에러가 원인이 매우 다양한데 응답 메시지는 대체로 뭉뚱그려져 나온다는 점입니다.

이 글은 PKCE에서 invalid_grant가 발생하는 대표 원인 7가지를 실제 트러블슈팅 관점에서 분류하고, 각각에 대해 재현 포인트, 로그 확인 지점, 해결책을 제공합니다.

참고로 invalid_grant는 주로 토큰 엔드포인트에서 grant_type=authorization_code코드 교환을 시도할 때 발생합니다.

  • Authorization Endpoint: response_type=code 발급
  • Token Endpoint: code + code_verifier로 교환

아래 원인들은 IdP(Authorization Server)가 무엇이든(Okta, Auth0, Cognito, Keycloak, 자체 구현) 공통적으로 자주 등장합니다.

관련해서 “원인별 체크리스트” 방식의 글이 도움이 된다면, 인프라 장애를 같은 방식으로 정리한 글인 K8s CrashLoopBackOff 원인별 진단 체크리스트도 함께 참고하면 좋습니다.

PKCE에서 invalid_grant가 뜨는 위치

대부분 다음 요청에서 터집니다.

curl -X POST "https://idp.example.com/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "client_id=my-client" \
  -d "code=AUTH_CODE" \
  -d "redirect_uri=myapp://callback" \
  -d "code_verifier=VERIFIER"

정상이라면 access_token이 오고, 실패하면 보통 이런 형태입니다.

{
  "error": "invalid_grant",
  "error_description": "..."
}

이제부터는 invalid_grant7가지로 쪼개서 봅니다.

원인 1) redirect_uri 불일치

증상

  • Authorization 요청에서 사용한 redirect_uri와 Token 요청에서 보낸 redirect_uri완전히 동일하지 않으면 실패합니다.
  • 일부 서버는 invalid_grant로, 일부는 invalid_request로 주지만 현실에서는 invalid_grant가 흔합니다.

흔한 실수 패턴

  • 스킴만 같고 뒤의 경로가 다름: myapp://callback vs myapp://callback/
  • URL 인코딩 차이: 공백, 쿼리 파라미터 인코딩
  • 웹에서 https://a.com/cb로 발급받고, 교환은 https://a.com/cb?from=app로 시도

해결

  • Authorization 요청에 넣은 redirect URI 문자열을 그대로 저장해두고 Token 교환 시 재사용하세요.
  • 서버에 등록된 redirect URI 목록과도 정확히 매칭되는지 확인합니다.

체크 코드(서버 로그)

서버에서 아래 두 값을 함께 로깅하면 진단이 빨라집니다.

issued_redirect_uri=...
request_redirect_uri=...

원인 2) Authorization Code 재사용 또는 중복 교환

증상

  • 같은 code로 토큰 교환을 두 번 하면 두 번째는 실패합니다.
  • 모바일에서 딥링크를 두 번 탭하거나, WebView가 리다이렉트를 두 번 로드하거나, 프론트에서 재시도 로직이 겹치면 쉽게 재현됩니다.

흔한 실수 패턴

  • 네트워크 타임아웃으로 클라이언트가 토큰 요청을 재시도했지만, 첫 요청은 서버에 도달해 이미 성공 처리됨
  • SPA에서 콜백 라우트가 마운트될 때마다 교환 로직이 실행됨
  • iOS에서 Universal Link가 앱/브라우저 양쪽에서 열리며 콜백이 중복 실행

해결

  • 토큰 교환 요청을 멱등하게 만들기 어렵기 때문에, 클라이언트에서 “한 번만 실행”을 강제합니다.
  • 콜백 처리 시 code를 로컬 저장소에 잠깐 저장하고, 이미 처리한 code면 교환을 스킵합니다.

예시(프론트엔드, 의사 코드):

const code = new URLSearchParams(location.search).get("code")
if (!code) throw new Error("missing code")

const key = `pkce:used-code:${code}`
if (sessionStorage.getItem(key)) {
  // 이미 교환 시도함
  return
}
sessionStorage.setItem(key, "1")

await exchangeToken(code)

운영에서 “중복 요청”은 세션/상태 꼬임과 함께 나타나는 경우가 많습니다. 비슷한 유형의 상태 문제를 다룬 글로 Spring Boot Redis 세션 꼬임 - TTL·동시로그인 해법도 참고할 만합니다.

원인 3) Authorization Code 만료(시간 문제)

증상

  • 로그인 페이지에서 오래 머물다가 돌아오면 실패
  • 모바일에서 앱이 백그라운드로 내려갔다가 복귀 후 교환하면 실패
  • 서버 시간이 틀어진 환경에서 간헐적으로 발생

배경

Authorization Code는 보통 수십 초~수 분로 매우 짧게 설정됩니다. PKCE라서 더 길게 주는 게 아니라, 오히려 짧게 주는 구현이 많습니다.

해결

  • 클라이언트 UX에서 로그인 완료 후 즉시 교환하도록 플로우를 단순화합니다.
  • 서버/클라이언트의 시간 동기화(NTP)를 확인합니다.
  • IdP 설정에서 code lifetime을 조정할 수 있으면 현실적인 값으로 조정합니다.

진단 팁

  • IdP 로그에서 “code issued at”과 “token request at” 타임스탬프 차이를 확인
  • 운영 환경의 NTP 상태 확인
timedatectl status

원인 4) code_verifier 불일치(저장/복원 실패)

증상

  • 동일 기기에서도 간헐적으로 실패
  • iOS Safari/인앱브라우저에서 특히 재현
  • “로그인 화면은 정상인데 콜백 이후 토큰 교환만 실패” 패턴

배경

PKCE 핵심은 code_challenge(요청 시)와 code_verifier(교환 시)가 같은 쌍이어야 한다는 점입니다.

흔히 발생하는 원인은 다음입니다.

  • code_verifier를 메모리에만 저장했다가 리다이렉트로 앱이 재시작되며 유실
  • 여러 로그인 시도를 동시에 시작해 verifier가 덮어써짐
  • 스토리지 키 충돌(예: 고정 키 하나만 사용)

해결

  • state별로 code_verifier를 저장하세요.
  • 저장소는 리다이렉트/앱 재시작에도 유지되는 곳을 선택합니다(모바일은 Keychain/Keystore, 웹은 sessionStorage 등).

예시(웹):

function savePkce(state: string, verifier: string) {
  sessionStorage.setItem(`pkce:verifier:${state}`, verifier)
}

function loadPkce(state: string) {
  return sessionStorage.getItem(`pkce:verifier:${state}`)
}

추가로, code_verifier 스펙 요구사항도 확인하세요.

  • 길이: 43~128
  • 문자셋: URL-safe

원인 5) code_challenge_method 또는 해시 계산 오류

증상

  • 특정 플랫폼(예: Android만, 특정 브라우저만)에서 계속 실패
  • 서버에서 “verifier invalid”류의 로그가 남는데 클라이언트는 정상처럼 보임

흔한 실수 패턴

  • code_challenge_method=S256로 보냈는데 실제 code_challengeplain처럼 생성
  • Base64 인코딩을 Base64url로 바꾸지 않음
  • 패딩 문자 =를 제거하지 않음

올바른 S256 계산(개념)

  • code_challenge = BASE64URL(SHA256(code_verifier))

Node.js 예시:

import crypto from "crypto"

function base64Url(buf) {
  return buf.toString("base64")
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/g, "")
}

export function pkceChallenge(verifier) {
  const hash = crypto.createHash("sha256").update(verifier).digest()
  return base64Url(hash)
}

해결

  • 라이브러리를 신뢰할 수 있으면 직접 구현보다 검증된 라이브러리를 사용
  • 직접 구현했다면 Base64url 변환 및 패딩 제거를 반드시 확인

원인 6) client_id/앱 설정 불일치(Confidential vs Public 혼동 포함)

증상

  • 어떤 환경에서는 되는데(로컬) 운영에서만 실패
  • 앱 클라이언트를 바꿨더니 갑자기 invalid_grant

흔한 실수 패턴

  • 모바일/SPA는 Public Client인데, 서버 설정이 Confidential로 되어 client_secret을 요구하거나 검증 로직이 꼬임
  • 토큰 교환 시 다른 client_id로 요청(환경변수/빌드 설정 혼선)
  • Authorization 요청은 A 클라이언트, Token 요청은 B 클라이언트

해결

  • Authorization 요청과 Token 요청에서 client_id가 동일한지 캡처로 비교
  • IdP 콘솔에서 해당 클라이언트가 PKCE를 요구하는지, 허용하는지 확인
  • 모바일/SPA는 원칙적으로 client_secret을 앱에 넣지 않습니다(유출 위험)

진단을 위해 다음을 함께 로깅하세요.

client_id
grant_type
code
redirect_uri
code_challenge_method

원인 7) state 검증 실패 또는 로그인 트랜잭션 분실

증상

  • 보안상 state를 검증하는데, 콜백에서 state가 다르거나 없어서 플로우가 깨짐
  • IdP에 따라 state 불일치가 직접적으로 invalid_grant로 이어지기도 하고, 앱이 내부적으로 잘못된 코드 교환을 하면서 invalid_grant로 귀결되기도 합니다.

흔한 실수 패턴

  • 여러 탭에서 동시에 로그인 시도
  • 프록시/로드밸런서 뒤에서 세션 스티키가 깨져 state 저장소를 못 찾음
  • SameSite 쿠키 정책으로 인해 로그인 트랜잭션 쿠키가 누락

해결

  • state는 반드시 “요청 단위”로 저장하고, 콜백에서 1회성으로 소비하세요.
  • 서버에서 로그인 트랜잭션을 세션에 저장한다면, 로드밸런서 환경에서 세션 저장소(예: Redis)와 TTL을 점검합니다.

서버 측(의사 코드):

// /authorize 시작
session.set(`oauth:state:${state}`, { verifier, createdAt: Date.now() }, { ttlSec: 300 })

// /callback
const tx = session.get(`oauth:state:${state}`)
if (!tx) throw new Error("missing oauth transaction")
session.del(`oauth:state:${state}`)

세션/TTL 이슈는 “가끔만 실패”를 만들기 때문에 특히 까다롭습니다. 이런 유형의 점검 관점은 Spring Boot Redis 세션 꼬임 - TTL·동시로그인 해법과도 연결됩니다.

실전 디버깅 체크리스트(캡처해야 할 값)

invalid_grant를 빠르게 줄이려면, “한 번의 실패”에서 아래 값을 모두 모으는 것이 핵심입니다.

  • Authorization 요청 파라미터
    • client_id
    • redirect_uri
    • state
    • code_challenge
    • code_challenge_method
  • Token 요청 파라미터
    • client_id
    • redirect_uri
    • code
    • code_verifier
  • 시간 정보
    • code 발급 시각
    • token 교환 시각
  • 중복 여부
    • 같은 code로 요청이 2번 갔는지

가능하면 민감정보 마스킹을 적용해 로깅하세요.

code=ab12...yz
code_verifier=Kp9...Q

마무리

PKCE에서 invalid_grant는 “PKCE가 깨졌다”라기보다, 대개 다음 중 하나입니다.

  1. redirect_uri가 다름
  2. code를 두 번 교환함
  3. code가 만료됨
  4. code_verifier를 잃어버리거나 덮어씀
  5. S256 계산(Base64url) 구현이 틀림
  6. client_id/클라이언트 타입 설정이 뒤섞임
  7. state/트랜잭션 저장소가 꼬임

이 7가지를 순서대로 지우듯이 확인하면, 대부분의 invalid_grant는 재현 가능해지고 해결도 빨라집니다.