Published on

OAuth PKCE에서 invalid_grant 나는 7가지 원인

Authors

서론

PKCE(Proof Key for Code Exchange)는 모바일/SPA처럼 client_secret을 안전하게 보관하기 어려운 환경에서 OAuth 2.0 Authorization Code 흐름을 안전하게 만드는 핵심 장치입니다. 그런데 실제 연동을 하다 보면 토큰 엔드포인트에서 invalid_grant(또는 유사한 “grant가 유효하지 않다” 류의 오류)를 꽤 자주 만납니다.

문제는 invalid_grant가 너무 포괄적이라는 점입니다. 코드가 만료됐는지, redirect_uri가 조금이라도 달라졌는지, code_verifier가 틀렸는지, 한 번 이미 사용했는지 등 원인이 여러 갈래인데, 서버는 보안상 상세 사유를 숨기는 경우가 많습니다.

이 글에서는 PKCE에서 invalid_grant를 만드는 7가지 대표 원인을 “증상 → 진단 포인트 → 해결” 형태로 정리합니다. 네트워크/클러스터 환경에서 간헐적으로만 터지는 케이스도 다루며, 운영에서 재발 방지할 수 있는 로깅 팁까지 포함합니다.

> 네트워크 레벨 이슈가 의심될 때는 인증 서버까지의 경로/타임아웃/프록시를 먼저 점검하세요. 특히 쿠버네티스 환경에서는 DNS/Ingress가 원인이 되는 경우가 많습니다. 참고: AWS EKS CoreDNS CrashLoopBackOff와 DNS 타임아웃 해결


1) code_verifier 불일치(가장 흔함)

증상

  • /token 호출이 항상 invalid_grant
  • 같은 authorization_code로 재시도해도 동일
  • 브라우저/앱에서는 로그인 성공했는데 마지막 토큰 교환에서 실패

원인

PKCE의 핵심은 code_challenge = BASE64URL(SHA256(code_verifier)) 입니다. 인가 요청에서 보낸 code_challenge와 토큰 교환 시 보낸 code_verifier1바이트라도 다르면 인증 서버는 invalid_grant로 거절합니다.

실무에서 흔한 불일치 원인:

  • code_verifier를 URL 인코딩/디코딩하면서 값이 변형
  • base64url이 아니라 일반 base64 사용(+, /, = 포함)
  • 문자열 정규화(NFC/NFD), 개행, 공백 포함
  • 앱 재시작/리다이렉트 중에 verifier를 잃어버려 다른 값으로 교환 시도

해결

  • verifier는 원문 그대로 저장/전달(절대 trim/encode 변형 금지)
  • base64url 인코딩을 정확히 구현
  • 토큰 교환 직전, “내가 보낸 verifier로 challenge를 다시 계산”해 비교 로그를 남기기

TypeScript 예제(정상 PKCE 생성)

// Node.js 18+ / Web Crypto 기반
import { webcrypto } from "node:crypto";

function base64url(input: Uint8Array) {
  return Buffer.from(input)
    .toString("base64")
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/g, "");
}

export function generateCodeVerifier() {
  const bytes = new Uint8Array(32);
  webcrypto.getRandomValues(bytes);
  // RFC 7636: 43~128 chars 권장. 여기서는 base64url로 43자 이상 확보
  return base64url(bytes);
}

export async function toCodeChallengeS256(verifier: string) {
  const data = new TextEncoder().encode(verifier);
  const digest = await webcrypto.subtle.digest("SHA-256", data);
  return base64url(new Uint8Array(digest));
}

// 사용 예
const verifier = generateCodeVerifier();
const challenge = await toCodeChallengeS256(verifier);
console.log({ verifier, challenge });

2) redirect_uri 불일치(미세한 차이 포함)

증상

  • 인가 코드는 정상 발급되지만 /token에서 invalid_grant
  • 특정 환경(스테이징/프로덕션)에서만 발생

원인

대부분의 OAuth 서버는 인가 요청의 redirect_uri토큰 교환 요청의 redirect_uri완전히 동일해야 합니다.

자주 놓치는 차이:

  • https://app.com/callback vs https://app.com/callback/ (슬래시)
  • 쿼리스트링 유무/순서 차이
  • 프록시/Ingress 뒤에서 http로 인식되는 문제(원래는 https)
  • 포트 포함 여부(:443, :3000)

해결

  • 인가 요청과 토큰 요청에서 동일한 redirect_uri 상수를 사용
  • 프록시 환경에서는 X-Forwarded-Proto, X-Forwarded-Host를 신뢰하도록 앱/게이트웨이 설정
  • OAuth 서버에 등록된 redirect URI 목록을 환경별로 명확히 분리

> Ingress/ALB 앞단에서 스킴/호스트가 바뀌는 문제는 인증뿐 아니라 전반적인 장애로 이어집니다. 상황이 비슷하다면 EKS ALB Ingress 504인데 Pod는 정상일 때도 함께 점검 포인트가 됩니다.


3) Authorization Code 만료(또는 시계 오차)

증상

  • 로그인 후 잠시 기다렸다가 토큰 교환하면 실패
  • 모바일에서 백그라운드 전환 후 복귀 시 실패율 증가

원인

Authorization Code는 수명이 매우 짧습니다(수십 초~수분). 사용자가 로그인 후 앱이 느리게 동작하거나, 네트워크가 불안정하거나, 백그라운드로 빠졌다가 복귀하면 만료될 수 있습니다.

또한 서버/클라이언트/인증 서버 간 **시간 동기화(NTP)**가 깨져 있으면 “아직 유효한데 만료로 판단” 같은 문제가 생길 수 있습니다.

해결

  • 인가 코드 수신 후 즉시 토큰 교환(UX 상 지연 로직 최소화)
  • 모바일/SPA에서 “코드 받은 시각”을 저장하고 일정 시간 초과 시 재인증
  • 서버 노드들의 NTP 동기화 확인(컨테이너/노드 모두)

4) Authorization Code 재사용(중복 교환)

증상

  • 첫 토큰 교환은 성공했는데, 같은 코드로 한 번 더 요청하면 invalid_grant
  • 간헐적으로만 발생(레이스 컨디션)

원인

Authorization Code는 1회성(one-time) 입니다. 다음과 같은 상황에서 “나도 모르게” 두 번 교환을 시도할 수 있습니다.

  • 프론트에서 더블 클릭/중복 제출
  • 콜백 처리 라우트가 두 번 실행(리다이렉트/새로고침)
  • 서버에서 재시도 로직이 멱등성을 보장하지 않음
  • SPA 라우터가 hydration 과정에서 effect가 중복 실행

해결

  • 콜백 처리에서 코드 소비를 멱등화(이미 처리한 code면 무시)
  • 프론트는 중복 제출 방지
  • 서버 재시도는 “네트워크 오류”에만 제한하고, 동일 code로 무한 재시도 금지

> UI 쪽 중복 제출은 인증 플로우에서도 치명적입니다. 폼/액션 기반이라면 React 19 useActionState로 폼 지연·중복 제출 해결처럼 중복 방지 패턴을 적용하는 게 도움이 됩니다.


5) code_challenge_method/알고리즘 불일치(S256 vs plain)

증상

  • 특정 OAuth 공급자에서만 invalid_grant
  • 구현체에 따라 어떤 곳은 되고 어떤 곳은 안 됨

원인

PKCE는 code_challenge_methodS256 또는 plain을 사용합니다.

  • 요즘 대부분의 공급자는 S256만 허용하거나 S256을 강하게 권장
  • 인가 요청에는 S256을 보냈는데 실제 challenge 계산은 plain처럼 보내는 실수
  • 반대로 서버가 plain을 기대하는데 S256을 보내는 경우(드묾)

해결

  • 인가 요청에 code_challenge_method=S256을 명시
  • challenge 계산이 SHA-256 + base64url인지 재검증
  • 공급자 문서에서 “plain 허용 여부” 확인

인가 요청 예시

GET /authorize?
  response_type=code&
  client_id=...&
  redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&
  scope=openid%20profile&
  state=...&
  code_challenge=...&
  code_challenge_method=S256

6) 토큰 요청 파라미터/인증 방식 오류(client_id, grant_type 등)

증상

  • 공급자에 따라 invalid_grant 또는 unauthorized_client 등으로 섞여 나옴
  • “분명 코드와 verifier는 맞는데” 계속 실패

원인

토큰 엔드포인트는 공급자마다 미묘한 요구사항이 있습니다.

대표 실수:

  • grant_type=authorization_code 누락/오타
  • client_id를 바디가 아닌 다른 곳에 보내거나(또는 반대)
  • Content-Typeapplication/x-www-form-urlencoded가 아닌 JSON
  • 퍼블릭 클라이언트인데 client_secret을 보내서 정책에 걸림
  • redirect_uri를 생략했는데 서버가 필수로 요구

해결

  • RFC 표준 형태로 요청을 고정하고, 공급자 문서의 예제와 1:1 비교
  • HTTP 레벨에서 실제 전송된 바디를 캡처(프록시/SDK가 바꿔치기하는 경우 있음)

curl 예제(가장 호환성 높은 토큰 교환)

curl -sS -X POST "https://auth.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=AUTHORIZATION_CODE" \
  --data-urlencode "redirect_uri=https://app.example.com/callback" \
  --data-urlencode "code_verifier=YOUR_CODE_VERIFIER"

7) 상태 저장/세션 꼬임(멀티탭, 로드밸런싱, 스토리지)

증상

  • 한 기기에서는 잘 되는데 특정 브라우저/사파리/인앱 브라우저에서 실패
  • 멀티탭에서 로그인 시도 시 실패율 증가
  • 서버를 스케일아웃한 뒤부터 간헐적으로 invalid_grant

원인

PKCE 자체는 “서버 세션이 없어도 가능”하지만, 실제 구현에서는 다음 상태를 어딘가에 저장합니다.

  • state (CSRF 방지)
  • code_verifier (토큰 교환에 필요)
  • nonce (OIDC 사용 시)

이 상태가 서로 다른 요청 사이에서 일관되게 유지되지 않으면 verifier가 뒤섞여 invalid_grant가 납니다.

대표 케이스:

  • 멀티탭 로그인으로 state/verifier가 서로 덮어씀(스토리지 키를 고정으로 써버림)
  • 쿠키 SameSite 정책으로 콜백 요청에 세션 쿠키가 안 붙음
  • 로드밸런서 뒤에서 세션 스티키가 없고, 서버 메모리에 verifier를 저장(노드 A에서 발급, 노드 B에서 교환)
  • 사파리/인앱 브라우저에서 스토리지 정책(ITP)으로 저장이 날아감

해결

  • verifier/state를 요청 단위 키로 저장(예: state를 키로 매핑)
  • 세션을 서버 메모리에 두지 말고 Redis 같은 공유 스토리지 사용 또는 완전 무상태로 설계
  • SameSite/secure 쿠키 정책 점검(특히 크로스사이트 리다이렉트)

state를 키로 verifier를 저장하는 간단 예시(서버)

// pseudo: Redis에 state -> verifier 매핑
// state는 충분히 랜덤해야 하며, TTL을 짧게(예: 5분)

await redis.setex(`pkce:${state}`, 300, codeVerifier);

// 콜백에서 code/state를 받으면
const verifier = await redis.get(`pkce:${state}`);
if (!verifier) throw new Error("missing verifier (state mismatch/expired)");

// 교환 후 즉시 삭제(재사용 방지)
await redis.del(`pkce:${state}`);

운영에서 바로 쓰는 진단 체크리스트

아래를 순서대로 확인하면 invalid_grant의 80%는 빠르게 좁혀집니다.

  1. 인가 요청/토큰 요청의 redirect_uri가 완전히 동일한가?(슬래시, 쿼리 포함)
  2. 토큰 요청이 form-urlencoded인가? SDK가 JSON으로 보내지 않는가?
  3. 동일 code로 두 번 교환 시도하지 않았나?(중복 제출/재시도/새로고침)
  4. code_verifier 원문이 변형되지 않았나?(trim, URL decode, base64 vs base64url)
  5. S256 계산이 정확한가?(SHA-256 → base64url, 패딩 제거)
  6. 코드 만료 시간 내에 교환했나?(백그라운드, 네트워크 지연)
  7. 멀티탭/스케일아웃에서 상태 저장이 안전한가?(state 키 분리, 공유 스토리지, 쿠키 정책)

마무리

PKCE에서 invalid_grant는 “PKCE가 깨졌다”가 아니라, 대개 값의 미세한 불일치(redirect_uri/verifier), 일회성 코드의 특성(만료/재사용), 또는 상태 저장의 일관성 문제(멀티탭/로드밸런싱)로 귀결됩니다.

가장 좋은 접근은 (1) 인가 요청과 토큰 요청을 한 쌍으로 묶어 원문 로그를 남기고, (2) state를 중심으로 verifier를 안전하게 매핑하며, (3) 토큰 교환을 멱등하게 만드는 것입니다. 이 3가지만 지켜도 invalid_grant는 “가끔 터지는 미스터리”에서 “재현 가능한 버그”로 바뀝니다.