Published on

Auth0 OAuth PKCE invalid_grant 원인과 해결

Authors

서드파티 IdP가 아니라 Auth0를 쓰는데도 OAuth 로그인 마지막 단계에서 invalid_grant가 뜨면, 대부분은 “토큰 엔드포인트에서 Authorization Code를 Access Token으로 교환하는 과정”에서 서버가 코드 교환을 거부했다는 뜻입니다. 특히 SPA나 모바일에서 PKCE를 쓰는 경우, code_verifier 보관/전달이 조금만 어긋나도 바로 invalid_grant로 귀결됩니다.

이 글은 Auth0 PKCE 플로우에서 invalid_grant가 나는 대표 원인을 증상별로 빠르게 좁히고, 로그와 네트워크 캡처로 확정한 뒤, 재발 방지까지 정리합니다.

invalid_grant가 의미하는 것 (Auth0 관점)

OAuth 2.0에서 invalid_grant는 “제출한 grant(인가 코드, 리프레시 토큰 등)가 유효하지 않다”는 포괄적 오류입니다. PKCE 플로우에서 가장 흔한 발생 지점은 아래 요청입니다.

  • 프런트엔드가 /authorize로 인가 요청을 보냄
  • Auth0가 code를 포함해 redirect_uri로 리다이렉트
  • 프런트엔드가 /oauth/tokencodecode_verifier를 제출
  • Auth0가 검증 실패 시 invalid_grant

즉, invalid_grant는 대개 토큰 교환 단계에서 발생하며, 원인은 크게 5가지 축으로 나뉩니다.

  1. code_verifier 불일치(저장/전달 문제 포함)
  2. redirect_uri 불일치
  3. 인가 코드 재사용 또는 만료
  4. 잘못된 클라이언트/테넌트/도메인으로 교환 요청
  5. 앱의 라우팅/세션/스토리지 정책(특히 iOS/Safari/프라이빗 모드)으로 PKCE 상태가 깨짐

가장 흔한 원인 1: code_verifier 불일치

전형적인 증상

  • 로그인 페이지까지는 정상
  • 리다이렉트로 code를 받았는데 /oauth/token에서 invalid_grant
  • 같은 환경에서 “가끔” 실패(특정 브라우저, 탭, 프라이빗 모드)

왜 발생하나

PKCE에서 Auth0는 code_challenge/authorize 단계에서 받고, /oauth/token 단계에서 code_verifier를 받아 둘이 일치하는지 검증합니다.

문제는 code_verifier클라이언트 측 임시 상태라는 점입니다.

  • SPA는 보통 메모리/세션스토리지/로컬스토리지 등에 저장
  • 리다이렉트(페이지 새로고침 포함) 이후 동일한 값이 복원되어야 함

아래 같은 경우에 code_verifier가 바뀌거나 사라집니다.

  • 로그인 시작 직후 앱이 리로드되어 PKCE 상태가 초기화
  • 여러 탭에서 동시에 로그인 시도(마지막 시도가 verifier를 덮어씀)
  • Safari ITP, iOS WebView, 프라이빗 모드에서 스토리지 정책으로 값이 소실
  • 커스텀 구현 시 code_verifier 생성/저장 로직이 요청마다 달라짐

해결 체크리스트

  • 로그인 트랜잭션 동안 code_verifier를 안정적으로 보관
  • 동시 로그인 시도를 막거나, 트랜잭션 키를 분리해 저장
  • 가능하면 Auth0 공식 SDK(예: @auth0/auth0-spa-js) 사용

재현/진단 팁

브라우저 DevTools에서 네트워크를 확인하세요.

  • /authorize 요청에 code_challenge가 포함되는지
  • /oauth/token 요청 바디에 code_verifier가 포함되는지

또한 Auth0 테넌트 로그에서 실패 이벤트의 상세를 확인합니다.

가장 흔한 원인 2: redirect_uri 불일치

전형적인 증상

  • 로컬에서는 되는데 스테이징/프로덕션에서만 실패
  • 특정 경로에서만 실패(예: /callback vs /auth/callback)

왜 발생하나

OAuth에서 redirect_uri는 보안상 매우 엄격합니다.

  • /authorize 요청 시의 redirect_uri
  • /oauth/token 요청 시의 redirect_uri

이 둘이 완전히 동일해야 하는 구현/설정 조합이 많습니다. (라이브러리마다 다르지만, 안전하게는 동일하게 맞추는 것이 정석입니다.)

특히 아래가 흔합니다.

  • 트레일링 슬래시 차이: https://app.example.com/callback vs https://app.example.com/callback/
  • 스킴 차이: http vs https
  • 포트 차이: http://localhost:3000/callback vs http://localhost:5173/callback
  • 프록시 뒤에서 원래 호스트를 잃어버림(예: X-Forwarded-Proto 미반영)

해결 체크리스트

  • Auth0 대시보드의 Allowed Callback URLs에 실제 콜백 URL을 정확히 등록
  • 앱에서 사용하는 콜백 URL을 환경변수로 단일화
  • 프록시/로드밸런서 환경에서는 원본 프로토콜/호스트가 보존되도록 설정

프록시가 개입하는 문제는 OAuth뿐 아니라 다양한 장애 원인이 됩니다. 인프라 레벨에서 헤더/리다이렉트가 꼬이는 패턴은 아래 글의 진단 흐름도 참고할 만합니다.

원인 3: authorization code 재사용 또는 만료

전형적인 증상

  • 로그인 직후 새로고침하면 실패
  • 뒤로가기/앞으로가기를 반복하면 실패
  • 네트워크가 느릴 때만 실패

왜 발생하나

Authorization Code는 1회성이며 보통 수명이 매우 짧습니다. 다음 상황에서 invalid_grant가 납니다.

  • 동일한 code/oauth/token을 두 번 호출
  • 콜백 처리 로직이 중복 실행(React Strict Mode, 이중 마운트, 라우터 가드 중복)
  • 콜백 URL을 여러 번 로드(사용자 새로고침, Service Worker 재시도)
  • 코드가 만료될 정도로 교환이 지연

해결 체크리스트

  • 콜백 페이지에서 토큰 교환 로직이 단 한 번만 실행되게 가드
  • 콜백 처리 후 즉시 history.replaceState 등으로 URL에서 code 제거
  • React 개발 모드에서 Strict Mode로 인한 이중 실행을 고려

예시(React)로, 콜백에서 쿼리 파라미터를 한 번만 처리하는 패턴입니다.

import { useEffect, useRef } from "react";

export function Callback() {
  const ran = useRef(false);

  useEffect(() => {
    if (ran.current) return;
    ran.current = true;

    // 여기서 Auth0 SDK의 handleRedirectCallback 같은 함수를 호출
    // 처리 후 URL에서 code/state 제거(중복 교환 방지)
    window.history.replaceState({}, document.title, "/");
  }, []);

  return null;
}

원인 4: 다른 도메인/클라이언트로 토큰 교환

전형적인 증상

  • 멀티 테넌트/멀티 환경에서 특정 환경만 실패
  • 커스텀 도메인 적용 후 실패

왜 발생하나

/authorize를 보낸 도메인과 /oauth/token을 호출한 도메인이 섞이면 문제가 납니다.

  • https://tenant.us.auth0.com/authorize로 시작했는데
  • 토큰 교환은 https://login.example.com/oauth/token으로 호출

또는 반대로 커스텀 도메인으로 시작했는데 기본 도메인으로 교환하는 경우도 있습니다.

또한 client_id가 환경별로 다른데, 콜백에서 잘못된 client_id로 교환하면 invalid_grant로 떨어질 수 있습니다.

해결 체크리스트

  • issuer(또는 Auth0 domain)와 토큰 엔드포인트를 한 가지로 통일
  • 환경변수로 AUTH0_DOMAIN, AUTH0_CLIENT_ID, AUTH0_AUDIENCE 등을 명확히 분리
  • 커스텀 도메인 사용 시 SDK 설정도 동일 도메인을 사용

원인 5: state/nonce 또는 트랜잭션 스토리지 문제

PKCE는 code_verifier뿐 아니라 state(CSRF 방지), OIDC라면 nonce도 함께 관리합니다. SDK는 이를 “트랜잭션”으로 저장해두는데, 저장소가 깨지면 다음과 같은 형태로 실패합니다.

  • 탭 간 이동/리다이렉트 과정에서 스토리지 키가 충돌
  • Safari에서 서드파티 쿠키/스토리지 제약
  • 앱이 콜백을 처리하기 전에 강제 라우팅(가드가 먼저 실행)

해결 체크리스트

  • 콜백 라우트는 인증 가드에서 예외 처리
  • 동일 도메인/동일 오리진에서 로그인 플로우를 유지
  • iOS WebView라면 시스템 브라우저 기반(ASWebAuthenticationSession 등) 사용 고려

Auth0 로그로 원인 확정하는 법

대부분의 경우, “앱에서 보는 에러 문자열”만으로는 부족합니다. Auth0 대시보드의 Logs에서 실패 이벤트를 열어 다음을 확인하세요.

  • 실패한 요청이 /oauth/token인지
  • client_id가 기대한 값인지
  • redirect_uri가 어떤 값으로 들어왔는지
  • 에러 설명이 Invalid authorization code 계열인지, PKCE verification failed 계열인지

추가로, 네트워크 탭에서 /oauth/token 요청 payload를 확인해 아래를 비교합니다.

  • code
  • code_verifier
  • redirect_uri
  • grant_typeauthorization_code인지

(권장) Auth0 SPA SDK로 PKCE 구현 예시

직접 PKCE를 구현하면 작은 실수로 invalid_grant가 나기 쉽습니다. 가능하면 Auth0 SPA SDK를 사용하세요.

import createAuth0Client from "@auth0/auth0-spa-js";

const auth0 = await createAuth0Client({
  domain: process.env.NEXT_PUBLIC_AUTH0_DOMAIN!,
  clientId: process.env.NEXT_PUBLIC_AUTH0_CLIENT_ID!,
  authorizationParams: {
    redirect_uri: window.location.origin + "/callback",
    audience: process.env.NEXT_PUBLIC_AUTH0_AUDIENCE,
  },
  cacheLocation: "memory", // 필요 시 "localstorage" 고려(보안/정책 트레이드오프)
});

// 로그인 시작
export async function login() {
  await auth0.loginWithRedirect();
}

// 콜백 처리
export async function handleCallback() {
  const result = await auth0.handleRedirectCallback();
  // 중복 처리 방지: code/state 제거
  window.history.replaceState({}, document.title, "/");
  return result;
}

cacheLocation 선택 주의

  • memory: XSS에 상대적으로 안전하지만 새로고침/탭 전환에 취약할 수 있음
  • localstorage: 새로고침에 강하지만 XSS 위험이 증가

조직의 보안 정책과 사용 환경(iOS/Safari 비중, 임베디드 WebView 여부)을 함께 고려해야 합니다.

직접 구현 시 흔한 실수(샘플)

직접 PKCE를 구현한다면 최소한 아래를 지켜야 합니다.

  • code_verifier는 43자 이상 고엔트로피 문자열
  • code_challengeS256로 SHA-256 후 base64url 인코딩
  • base64url은 +-로, /_로, 패딩 = 제거

Node.js(브라우저도 Web Crypto로 유사)에서 S256 챌린지를 만드는 예시입니다.

import crypto from "crypto";

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

export function createPkcePair() {
  const verifier = base64url(crypto.randomBytes(32));
  const challenge = base64url(crypto.createHash("sha256").update(verifier).digest());
  return { verifier, challenge, method: "S256" as const };
}

여기서 verifier를 저장할 때 “동시 로그인 시도”를 고려하지 않으면 덮어쓰기 문제가 생깁니다. 예를 들어 sessionStorage에 고정 키로 저장하면 탭/시도 간 충돌이 날 수 있으니, 트랜잭션 단위 키(예: state를 키로 사용)를 고려하세요.

운영 환경에서 재발 방지: 체크리스트 12개

  1. /authorize/oauth/token이 같은 Auth0 도메인(커스텀 도메인 포함)을 사용
  2. 콜백 URL을 환경별로 단일화하고 Allowed Callback URLs와 정확히 일치
  3. 콜백 라우트는 인증 가드에서 예외 처리
  4. 콜백 처리 로직은 1회 실행 보장(Strict Mode, 중복 마운트 방지)
  5. 처리 후 URL에서 code/state 제거
  6. 동시 로그인 시도(여러 탭) 시 트랜잭션 저장소 충돌 방지
  7. Safari/iOS/WebView에서 스토리지 정책 검증(프라이빗 모드 포함)
  8. 클록 스큐가 큰 환경(가상화/컨테이너)이라면 시간 동기화 점검
  9. 프록시 뒤라면 원본 스킴/호스트가 유지되도록 X-Forwarded-* 처리
  10. 네트워크 재시도 로직이 토큰 교환을 중복 호출하지 않게 설계
  11. Auth0 로그에서 실패 이벤트의 상세 필드를 수집/알림
  12. 가능하면 공식 SDK 사용으로 트랜잭션 관리 위임

인증 플로우에서 “무한 리다이렉트”나 “콜백 처리 중복”은 invalid_grant로도 나타나곤 합니다. 비슷한 증상 패턴을 다룬 글도 함께 보면 원인 분해에 도움이 됩니다.

결론

Auth0 PKCE의 invalid_grant는 대개 서버가 나쁘다기보다, 클라이언트가 들고 있어야 할 트랜잭션 상태(code_verifier, state, redirect_uri)가 리다이렉트 과정에서 깨졌다는 신호입니다.

가장 빠른 해결 루트는 다음 순서입니다.

  1. DevTools에서 /oauth/token 요청 payload 확인(code_verifier, redirect_uri)
  2. Auth0 Logs에서 실패 이벤트 상세 확인
  3. 콜백 처리 중복 실행/재시도/새로고침으로 코드 재사용이 있는지 확인
  4. 도메인/리다이렉트 URI/커스텀 도메인 혼용 여부 정리
  5. 스토리지 정책이 까다로운 브라우저(iOS/Safari/WebView)에서 재현 테스트

이 과정을 통과하면 invalid_grant는 대부분 재현 가능하고, 재현 가능하면 해결도 가능합니다.