Published on

Auth0 OAuth PKCE invalid_grant 디버깅 10가지

Authors

서버/클라이언트가 OAuth 2.0 Authorization Code with PKCE를 붙이는 순간, 가장 자주 마주치는 에러가 invalid_grant 입니다. 문제는 이 에러가 “그랜트가 유효하지 않다”라는 뭉뚱그린 메시지로만 나타나서, 원인이 code_verifier 불일치인지, 리다이렉트 URI 불일치인지, 코드 재사용인지, 혹은 환경변수 꼬임인지 바로 감이 안 온다는 점입니다.

이 글은 Auth0를 기준으로 PKCE에서 invalid_grant가 나는 케이스를 10가지로 쪼개서, 어떤 로그/요청을 어디서 확인해야 하는지, 그리고 재현 및 수정 포인트를 실무 관점으로 정리합니다. 특히 “Authorization 요청은 성공하지만 토큰 교환에서만 실패”하는 패턴을 중심으로 다룹니다.

참고로 이런 류의 인증/권한 문제는 증상이 비슷해도 원인이 여러 갈래로 갈라지기 때문에, 체크리스트 형태로 순서대로 배제해 나가는 것이 가장 빠릅니다. 비슷한 결의 트러블슈팅 글로는 GitHub Actions OIDC 401 권한 오류 해결 가이드도 함께 참고하면, “요청 페이로드를 실제로 뜯어보며 원인을 좁히는 방식”에 감을 잡는 데 도움이 됩니다.

PKCE invalid_grant가 주로 터지는 지점

대부분 흐름은 아래 2단계입니다.

  1. /authorize로 이동 (사용자 로그인 및 동의)

  2. 리다이렉트로 받은 code/oauth/token에 보내서 토큰 교환

invalid_grant는 거의 항상 2)에서 발생합니다. 즉, “Authorization code는 받았는데 교환이 안 되는” 상황입니다. 그러면 디버깅은 /oauth/token 요청(헤더/바디/파라미터)과, 그 직전에 만들었던 PKCE 값(code_verifier, code_challenge)의 일관성을 검증하는 쪽으로 진행해야 합니다.

아래부터는 실전에서 많이 터지는 순서대로 10가지를 제시합니다.

1) code_verifier가 요청마다 바뀌는 문제 (가장 흔함)

PKCE에서 핵심은 “처음 /authorize를 시작할 때 만들었던 code_verifier를, 토큰 교환 시점까지 동일하게 유지”하는 것입니다.

다음 실수들이 특히 흔합니다.

  • 로그인 버튼을 두 번 눌러 /authorize가 두 번 나가고, 마지막에 저장된 code_verifier만 남음
  • SPA에서 페이지 리로드로 메모리 상태가 날아감
  • 서버에서 세션 저장이 안 되었는데 된 줄 알고 진행

점검 방법

  • /authorize 요청을 시작할 때 생성한 code_verifier를 로그로 남기고(민감정보이므로 개발 환경에서만), /oauth/token 요청 시점에 사용한 값과 같은지 비교
  • 브라우저에서는 sessionStorage 또는 cookie에 저장된 값이 실제로 유지되는지 확인

예시 코드 (브라우저에서 PKCE 생성 및 저장)

// pkce.ts
function base64UrlEncode(bytes: ArrayBuffer) {
  const bin = String.fromCharCode(...new Uint8Array(bytes));
  return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}

export async function createPkcePair() {
  const verifierBytes = crypto.getRandomValues(new Uint8Array(32));
  const verifier = base64UrlEncode(verifierBytes.buffer);

  const digest = await crypto.subtle.digest(
    'SHA-256',
    new TextEncoder().encode(verifier)
  );
  const challenge = base64UrlEncode(digest);

  // 토큰 교환까지 유지되어야 함
  sessionStorage.setItem('pkce_verifier', verifier);

  return { verifier, challenge };
}

2) code_challenge_method 불일치 또는 누락

Auth0는 일반적으로 S256을 쓰는 구성을 권장합니다. 그런데 /authorize에서 code_challenge_method=S256을 보내놓고, 실제 challenge가 plain 방식으로 계산된 값이거나(혹은 반대), 아예 code_challenge_method를 누락하는 경우가 있습니다.

점검 방법

  • /authorize 요청 URL에 code_challenge_method=S256이 들어 있는지
  • code_challenge가 진짜 SHA-256 기반 base64url 인코딩인지

예시 (authorize URL 구성)

const authorizeUrl = new URL('https://YOUR_DOMAIN/authorize');
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('client_id', process.env.NEXT_PUBLIC_AUTH0_CLIENT_ID!);
authorizeUrl.searchParams.set('redirect_uri', 'http://localhost:3000/callback');
authorizeUrl.searchParams.set('scope', 'openid profile email');
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
authorizeUrl.searchParams.set('code_challenge', challenge);

3) redirect_uri가 토큰 교환 시점에 1바이트라도 다름

OAuth에서 redirect_uri는 “Authorization 요청과 Token 요청에서 완전히 동일”해야 합니다. 스킴, 호스트, 포트, 경로, 슬래시 유무, URL 인코딩까지 조금이라도 달라지면 Auth0는 invalid_grant를 반환할 수 있습니다.

자주 보는 함정:

  • http://localhost:3000/callback vs http://localhost:3000/callback/
  • https://app.example.com/callback vs https://app.example.com/callback?foo=bar
  • 프록시 뒤에서 외부는 https인데 내부 앱은 http라고 생각함

점검 방법

  • /authorize에 사용한 redirect_uri 문자열을 그대로 로그로 남기고, /oauth/token 바디의 redirect_uri와 문자열 비교
  • 프록시 환경이면 X-Forwarded-Proto 처리 여부 확인

예시 (토큰 교환 요청)

curl -sS -X POST "https://YOUR_DOMAIN/oauth/token" \
  -H "content-type: application/json" \
  -d '{
    "grant_type": "authorization_code",
    "client_id": "YOUR_CLIENT_ID",
    "code": "AUTHORIZATION_CODE",
    "redirect_uri": "http://localhost:3000/callback",
    "code_verifier": "VERIFIER_FROM_STORAGE"
  }'

4) Authorization code 재사용(중복 교환) 또는 레이스 컨디션

Authorization code는 일회성입니다. 같은 code/oauth/token을 두 번 호출하면 두 번째는 보통 invalid_grant가 납니다.

이건 UI/서버 로직에서 레이스가 나면 쉽게 발생합니다.

  • 콜백 페이지가 마운트될 때 토큰 교환을 2번 호출(React Strict Mode 개발 환경에서 효과가 2번 실행되는 케이스 포함)
  • 서버 액션/라우트 핸들러가 재시도되어 같은 code를 다시 교환

점검 방법

  • 콜백 처리 로직이 한 번만 실행되는지 확인
  • 서버 로그에서 동일한 code/oauth/token 호출이 반복되는지 확인

방어 패턴

  • code를 교환한 즉시 세션에 “처리됨” 플래그를 남기고, 재진입 시 무시
  • 프론트에서는 code를 URL에서 제거(history.replaceState)하고, 교환 로직을 idempotent하게 구성

5) code_verifier 길이/문자셋이 RFC를 벗어남

PKCE code_verifier는 길이와 허용 문자 범위가 있습니다. 어떤 구현은 base64 표준 인코딩을 그대로 써서 +, /, =가 들어가거나, 너무 짧은 verifier를 만들기도 합니다.

점검 방법

  • verifier가 base64url 형태인지 확인(대체로 -, _ 사용, = 패딩 제거)
  • 길이가 너무 짧지 않은지 확인(실무에서는 43자 이상이 안전)

위의 createPkcePair() 예시는 base64url로 변환하고 패딩을 제거합니다.

6) 앱 타입/토큰 엔드포인트 인증 방식 혼동

Auth0에서 SPA(퍼블릭 클라이언트)는 보통 client secret 없이 PKCE로 갑니다. 그런데 토큰 교환 요청에 client_secret을 섞거나, 반대로 confidential client인데 secret 없이 보내는 구성 혼동이 생기면 에러가 엮여 보일 수 있습니다.

주의할 점:

  • SPA인데 백엔드에서 토큰 교환을 대신하면서 secret을 넣는 구조는 가능하지만, 그럴 거면 전체 플로우를 “백엔드가 코드 교환을 담당”하도록 일관되게 설계해야 합니다.
  • 프론트에서 secret을 쓰는 것은 금지입니다.

점검 방법

  • Auth0 애플리케이션 타입이 SPA인지 Regular Web App인지 확인
  • /oauth/token 호출 주체(브라우저인지 서버인지)와 그에 맞는 인증 방식을 재점검

7) 테넌트 도메인/Issuer 혼동 (커스텀 도메인 포함)

커스텀 도메인을 쓰는 경우 다음 혼동이 흔합니다.

  • /authorizehttps://login.example.com/authorize로 보냈는데
  • /oauth/tokenhttps://YOUR_TENANT.us.auth0.com/oauth/token으로 보냄

도메인이 섞이면 쿠키/세션/트랜잭션 상태가 기대와 다르게 동작하거나, 설정(Allowed Callback URLs 등)이 도메인 기준으로 다르게 적용되어 결과적으로 invalid_grant로 이어질 수 있습니다.

점검 방법

  • /authorize/oauth/token이 동일한 Auth0 도메인을 사용했는지 확인
  • SDK 설정의 domain, issuerBaseURL 같은 값이 환경별로 뒤섞이지 않았는지 확인

8) 시간 동기화 문제 또는 코드 만료

Authorization code는 짧은 TTL을 가집니다. 사용자가 로그인 후 콜백을 오래 방치했거나, 서버/클라이언트 시간이 비정상적으로 틀어져 있으면 만료로 invalid_grant가 날 수 있습니다.

점검 방법

  • 사용자 재현 시나리오에서 “로그인 후 몇 분 뒤에 교환했는지” 확인
  • 서버가 여러 대면 NTP 동기화 확인

Kubernetes/클라우드 환경에서는 노드 시간 문제가 직접 원인이 되는 경우는 드물지만, 만료/재시도/큐 적체가 겹치면 “결국 만료된 코드로 교환”하는 형태가 됩니다. 이런 식의 간헐적 장애를 추적하는 방식은 Next.js 14 서버액션 500·CSRF·캐시 꼬임 해결처럼 “요청 단위로 상관관계를 잡아내는 접근”이 유사합니다.

9) 프록시/로드밸런서 환경에서 콜백 처리 서버가 달라짐

백엔드가 PKCE verifier를 세션에 저장하는 구조라면, 콜백을 처리하는 서버 인스턴스가 달라지는 순간 verifier를 못 찾아서 잘못된 값(혹은 빈 값)으로 토큰 교환을 시도하게 됩니다.

예:

  • /authorize 시작 요청은 인스턴스 A에서 세션 저장
  • /callback은 인스턴스 B로 라우팅
  • 인스턴스 B는 세션에 verifier가 없어서 실패

점검 방법

  • 세션 저장소가 인메모리인지(인스턴스 로컬) 확인
  • 로드밸런서 sticky session이 필요한 구조인지, 혹은 Redis 같은 중앙 세션 저장소로 바꿔야 하는지 판단

개선 방향

  • 가능하면 “PKCE 트랜잭션 상태”는 중앙 세션/스토리지에 저장
  • 혹은 프론트에서 verifier를 저장하고 백엔드는 verifier를 신뢰하지 않는 방식으로 설계를 단순화(단, 보안 모델과 위협 모델을 재검토 필요)

10) 실제 요청/응답을 재현 가능한 형태로 캡처하지 못함

invalid_grant는 원인이 다양해서, 결국 마지막에는 “실제 나간 /oauth/token 요청 바디가 무엇이었는지”가 승부를 가릅니다. 그런데 많은 팀이 민감정보 때문에 로깅을 꺼리고, 그 결과 재현이 안 되어 시간을 소모합니다.

여기서 중요한 건 “민감정보를 안전하게 마스킹하면서도 디버깅 가능한 최소 정보를 남기는 것”입니다.

권장 로깅 포인트

  • redirect_uri (그대로 남겨도 되는 경우가 많음)
  • client_id (대체로 문제 없음)
  • code는 앞 6자 정도만 남기고 마스킹
  • code_verifier는 해시로 남기기(원문 금지)

예시 (Node.js에서 verifier 해시로 로깅)

import crypto from 'crypto';

function sha256Hex(input: string) {
  return crypto.createHash('sha256').update(input, 'utf8').digest('hex');
}

export function logPkceDebug(meta: {
  redirectUri: string;
  code?: string;
  verifier?: string;
}) {
  const maskedCode = meta.code ? `${meta.code.slice(0, 6)}...` : undefined;
  const verifierHash = meta.verifier ? sha256Hex(meta.verifier) : undefined;

  console.info('[auth] token-exchange debug', {
    redirect_uri: meta.redirectUri,
    code: maskedCode,
    verifier_sha256: verifierHash,
  });
}

이 정도만 남겨도 “서로 다른 요청에서 verifier가 바뀌었는지”, “redirect가 달랐는지”, “같은 code를 재사용했는지”를 상당히 빠르게 판별할 수 있습니다.

빠른 진단 순서 (현장용)

아래 순서대로 보면 대부분 10분 내로 범위를 좁힙니다.

  1. /authorize/oauth/token에 사용된 redirect_uri가 완전 동일한지
  2. code가 재사용되고 있지 않은지(콜백 로직 중복 실행)
  3. code_verifier가 동일 트랜잭션에서 유지되는지(저장/복원)
  4. code_challenge_methodS256이고, challenge 계산이 맞는지
  5. 도메인(커스텀 도메인 포함)과 환경변수가 섞이지 않았는지
  6. 서버가 여러 대면 세션 저장소/스티키 문제인지
  7. 만료/시간 문제인지

마무리: invalid_grant는 “정합성” 문제다

Auth0 PKCE의 invalid_grant는 대개 “암호학적으로 어려운 문제”가 아니라 “두 요청 사이의 정합성이 깨진 문제”입니다. 즉, /authorize에서 만들었던 컨텍스트(redirect_uri, code_challenge, 트랜잭션 상태)가 /oauth/token까지 정확히 이어졌는지만 증명하면 해결됩니다.

디버깅이 길어질수록 감으로 추측하기 쉬운데, 그럴수록 실제 HTTP 요청을 캡처하고(개발 환경), 민감정보는 해시/마스킹으로 남겨서 재현 가능한 형태로 만드는 것이 가장 빠른 길입니다. 이런 식의 체크리스트 기반 접근은 인증뿐 아니라 각종 권한/네트워크 이슈에서도 동일하게 통합니다. 예를 들어 OIDC/OAuth 계열 권한 이슈를 다룬 GitHub Actions OIDC 401 권한 오류 해결 가이드처럼, “요청을 쪼개고, 일치해야 하는 값을 표로 관리”하는 습관이 결국 장애 시간을 줄입니다.