Published on

Auth0 OAuth 400 invalid_grant - PKCE·redirect_uri 해결

Authors

서론

Auth0로 Authorization Code Flow(특히 SPA/모바일에서 권장되는 PKCE 포함)를 구현하다 보면, 로그인 화면까지는 정상인데 토큰 교환(/oauth/token) 단계에서 갑자기 400 invalid_grant로 막히는 경우가 자주 발생합니다. 문제는 에러 메시지가 포괄적이라 “코드가 잘못됐다” 정도로만 보이고, 실제 원인은 redirect_uri 미스매치, PKCE(code_verifier) 불일치, authorization code 재사용/만료, 잘못된 클라이언트 타입/시크릿 사용 등으로 갈립니다.

이 글은 “Auth0 OAuth 400 invalid_grant”를 원인별로 빠르게 분류하고, 재현 가능한 체크리스트와 함께 PKCE·redirect_uri 중심 해결법을 정리합니다. (마지막에 로그/네트워크 관찰 포인트와 실무 팁도 포함)


invalid_grant가 의미하는 것(정확히)

OAuth 2.0에서 invalid_grant는 대체로 아래 상황을 포함합니다.

  • authorization code가 유효하지 않음
    • 만료(expired)
    • 이미 사용됨(reused)
    • 발급된 client/redirect_uri와 불일치
  • PKCE 검증 실패
    • code_verifier가 없거나 다름
    • code_challenge 방식/값이 다름
  • (일부 IdP/구현체에서) 리프레시 토큰이 무효

Auth0에서도 토큰 엔드포인트에서 code를 교환할 때 위 조건이 하나라도 틀리면 invalid_grant로 떨어집니다. 따라서 “grant가 invalid”하다는 말은 토큰 교환 요청의 파라미터 정합성이 깨졌다는 뜻에 가깝습니다.


1) redirect_uri 미스매치: 가장 흔하고 가장 단순한 원인

증상

  • /authorize 요청은 성공해서 로그인 후 콜백으로 돌아오는데
  • /oauth/token에서 invalid_grant 발생

핵심 원리

Auth0는 authorization code를 발급할 때 사용된 redirect_uri와, 토큰 교환 시 전달한 redirect_uri가 완전히 동일해야 한다고 요구합니다.

여기서 “동일”은 단순히 도메인만이 아니라 스킴/호스트/포트/패스/트레일링 슬래시/URL 인코딩까지 포함합니다.

흔한 실수 패턴

  • http://localhost:3000/callback vs http://localhost:3000/callback/
  • http://localhost:3000/callback vs http://127.0.0.1:3000/callback
  • https://app.example.com/callback vs https://app.example.com/callback?foo=bar
    • 보통 토큰 교환의 redirect_uri에는 쿼리를 붙이지 않거나, 붙이면 authorize 때와 완전히 같아야 합니다.
  • 프록시/로드밸런서 뒤에서 외부는 https인데 내부에서 http로 redirect_uri를 구성

해결 체크리스트

  1. Auth0 대시보드 → Application → Settings
    • Allowed Callback URLs에 실제 콜백 URL을 정확히 등록
  2. /authorize 요청의 redirect_uri
  3. /oauth/token 요청의 redirect_uri문자열로 완전 동일한지 확인

재현/검증용 cURL 예시

아래는 “토큰 교환” 요청입니다. redirect_uri가 authorize 때와 조금이라도 다르면 실패할 수 있습니다.

curl --request POST \
  --url https://YOUR_DOMAIN/oauth/token \
  --header 'content-type: application/json' \
  --data '{
    "grant_type": "authorization_code",
    "client_id": "YOUR_CLIENT_ID",
    "code": "AUTHORIZATION_CODE_FROM_CALLBACK",
    "redirect_uri": "http://localhost:3000/callback",
    "code_verifier": "YOUR_CODE_VERIFIER"
  }'

> 팁: 브라우저 주소창에 찍힌 콜백 URL과, 코드에서 토큰 교환 시 사용한 redirect_uri를 그대로 비교해 보세요. 실무에서는 “환경변수로 redirect_uri를 따로 관리”하다가 dev/prod 값이 섞여 발생하는 경우가 많습니다.


2) PKCE 실패: code_verifier가 한 글자라도 다르면 invalid_grant

PKCE 요약

PKCE는 Authorization Code Flow에서 “코드를 훔쳐도 토큰 교환을 못 하게” 만드는 장치입니다.

  • 클라이언트가 랜덤 문자열 code_verifier 생성
  • 이를 해시/인코딩하여 code_challenge 생성
  • /authorizecode_challenge를 보냄
  • /oauth/token에 원본 code_verifier를 보냄
  • 서버가 검증: transform(code_verifier) == code_challenge

따라서 아래 중 하나라도 어긋나면 invalid_grant가 납니다.

  • code_verifier를 저장하지 못함(페이지 리로드/새 탭/스토리지 초기화)
  • 서로 다른 인스턴스(서버/클라이언트)에서 verifier를 생성
  • base64url 인코딩 규칙을 잘못 적용
  • code_challenge_methodS256로 보냈는데 실제는 plain으로 계산(또는 반대)

SPA에서 자주 터지는 이유

  • 로그인 리다이렉트로 인해 앱이 새로 로드되며 메모리 상태가 사라짐
  • code_verifier를 sessionStorage/localStorage에 저장해야 하는데
    • 저장 키가 환경별로 다르거나
    • 콜백 처리 전에 초기화 로직이 실행되거나
    • 멀티탭에서 경합이 생김

안전한 PKCE 생성/검증 예시 (Node/브라우저 공용 개념)

아래는 PKCE를 직접 구현할 때의 예시입니다. (실무에서는 Auth0 SDK 사용을 권장하지만, 원리 이해/디버깅에 도움이 됩니다.)

// pkce.js
function base64UrlEncode(buffer) {
  return btoa(String.fromCharCode(...new Uint8Array(buffer)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/g, '');
}

export async function createPkcePair() {
  // 43~128 chars 권장
  const random = crypto.getRandomValues(new Uint8Array(32));
  const codeVerifier = base64UrlEncode(random);

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

  return {
    codeVerifier,
    codeChallenge,
    codeChallengeMethod: 'S256'
  };
}

PKCE 디버깅 포인트

  • /authorize 요청 URL에 code_challenge, code_challenge_method=S256가 실제로 붙는지
  • 콜백에서 받은 code그 verifier와 같은 트랜잭션에서 생성된 것인지
  • /oauth/token에 보내는 code_verifier정확히 동일한지

> 특히 “콜백 페이지 진입 시 앱이 초기화되며 verifier 저장소를 지우는 코드”가 있으면 100% 재현됩니다.


3) authorization code 재사용/만료: 네트워크 재시도/중복 호출이 만드는 함정

증상

  • 첫 시도는 성공했는데, 특정 환경에서만 간헐적으로 invalid_grant
  • 혹은 개발 중 새로고침/뒤로가기 후 토큰 교환을 다시 하면서 실패

원리

authorization code는 보통 1회성이며, 짧은 TTL을 가집니다.

다음 상황에서 재사용이 발생합니다.

  • 콜백 처리 로직이 두 번 실행됨
    • React 18 StrictMode 개발 환경에서 effect가 2회 실행되는 패턴
    • 라우터 가드/초기화 코드가 중복으로 토큰 교환 호출
  • 네트워크 레벨 재시도(프록시/클라이언트)가 POST를 재전송
  • 사용자가 콜백 URL을 북마크/새로고침

해결

  • 콜백 처리에서 code를 한 번만 처리하도록 가드
  • 토큰 교환 요청에 대한 재시도 정책을 신중히(특히 멱등성 없음)
  • 처리 완료 후 즉시 history.replaceState 등으로 URL에서 code 제거
// callback-handler.js
const url = new URL(window.location.href);
const code = url.searchParams.get('code');

if (code) {
  const alreadyHandled = sessionStorage.getItem(`handled_code:${code}`);
  if (!alreadyHandled) {
    sessionStorage.setItem(`handled_code:${code}`, '1');

    // TODO: exchangeToken(code)

    // code 제거 (재로드/공유로 인한 재사용 방지)
    url.searchParams.delete('code');
    url.searchParams.delete('state');
    window.history.replaceState({}, '', url.toString());
  }
}

재시도/중복 호출 같은 “분산 시스템적 함정”은 OAuth에서도 그대로 나타납니다. 비슷한 성격의 장애 대응 관점은 gRPC MSA에서 DEADLINE_EXCEEDED 연쇄 장애 차단 글의 ‘연쇄 실패를 막는 설계’와도 통합니다.


4) redirect_uri 구성 실수: 프록시/배포 환경에서의 https 강제

클라우드 환경에서 프론트/백엔드가 프록시 뒤에 있을 때, 서버가 redirect_uri를 조립하면 아래 문제가 흔합니다.

  • 외부는 https://app.example.com/callback
  • 내부 요청은 http://service:8080/callback
  • 서버가 X-Forwarded-Proto를 무시하고 http로 redirect_uri를 만들면
    • authorize 때와 token 때 redirect_uri가 달라져 invalid_grant

해결

  • 서버에서 redirect_uri를 “요청 기반 조립”하지 말고 고정된 설정값으로 관리
  • 불가피하다면 X-Forwarded-Proto, X-Forwarded-Host를 신뢰하도록 프레임워크 설정

Spring Boot를 쓴다면 프록시 헤더 처리(ForwardedHeaderFilter 등) 설정이 redirect_uri 문제를 줄이는 데 도움이 됩니다. (프레임워크 레벨에서 원인 추적/근본 해결 접근은 Spring Boot 3 LazyInitializationException 근본 해결처럼 “증상 완화가 아니라 원인 제거”로 접근하는 것이 좋습니다.)


5) Auth0 설정에서 확인해야 할 항목(대시보드)

invalid_grant가 나올 때, 코드만 보지 말고 Auth0 애플리케이션 설정도 같이 점검해야 합니다.

Application Settings

  • Allowed Callback URLs: 콜백 URL 정확히
  • Allowed Logout URLs: 로그아웃 리다이렉트가 꼬이면 디버깅이 어려워짐
  • Allowed Web Origins: SPA면 오리진 등록 필수

Application Type

  • SPA인데 “Regular Web App”로 만들고 client_secret을 섞어 쓰면 흐름이 꼬일 수 있습니다.
  • 서버 사이드(Confidential client)에서 code 교환 시 client_secret 필요
  • SPA/모바일(Public client)에서 PKCE로 교환 시 보통 client_secret 없음

> 중요한 원칙: “누가 code를 교환하느냐”를 명확히 하세요. 프론트가 교환하면 PKCE, 백엔드가 교환하면 시크릿 기반(또는 백엔드도 PKCE 가능하지만 설계 일관성이 필요)으로 정리해야 합니다.


6) 로그/네트워크로 빠르게 원인 특정하는 방법

브라우저 DevTools에서 확인할 것

  • /authorize 요청 URL
    • redirect_uri, code_challenge, code_challenge_method, state
  • 콜백으로 돌아온 URL
    • code, state
  • /oauth/token 요청 payload
    • redirect_uri, code_verifier, code

여기서 authorize의 redirect_uri vs token의 redirect_uri를 복사해 텍스트 비교하면 1차 분류가 끝납니다.

Auth0 로그(Event Logs)

Auth0 대시보드의 로그에서 실패 이벤트를 보면, 경우에 따라 “redirect_uri mismatch” 같은 힌트가 더 구체적으로 남습니다. (테넌트/로그 레벨에 따라 상세도가 다를 수 있음)


7) 실무에서 자주 쓰는 해결 레시피 5개

  1. redirect_uri를 상수로 고정
    • 프론트/백엔드가 각각 “추측해서 조립”하지 않게 만들기
  2. PKCE verifier 저장소를 명확히
    • SPA는 sessionStorage 권장(탭 단위)
    • 콜백 처리 전에 초기화/로그아웃 로직이 실행되지 않게 순서 조정
  3. 콜백 처리 멱등성 확보
    • code 1회 처리 가드 + URL에서 code 제거
  4. StrictMode/이펙트 중복 실행 방지
    • 개발 환경에서만 발생하는 중복 교환을 제거
  5. SDK 사용 시 버전/설정 점검
    • Auth0 SPA SDK의 cacheLocation, useRefreshTokens, authorizationParams 설정이 환경과 맞는지 확인

코드/설정이 복잡해질수록 “한 번에 고치기”보다 “원인 후보를 빠르게 좁히는” 방식이 중요합니다. 이런 트러블슈팅 접근은 인프라 이슈에서도 동일하게 유효합니다. 예를 들어 EKS에서 kubectl port-forward 끊김·hang 해결처럼, 관찰 지점을 정해 원인을 단계적으로 배제하는 것이 시간을 크게 줄입니다.


결론

Auth0의 400 invalid_grant는 에러 문자열 자체는 단순하지만, 실제로는 토큰 교환 요청의 정합성 문제를 뭉뚱그려 표현한 결과입니다. 실무에서 가장 많이 맞닥뜨리는 원인은 다음 두 가지입니다.

  • redirect_uri가 authorize와 token에서 완전히 동일하지 않음
  • PKCE의 code_verifier/code_challenge가 서로 다른 트랜잭션이거나 인코딩/저장 문제가 있음

여기에 **code 재사용/만료(중복 호출)**까지 함께 점검하면, 대부분의 케이스는 10~20분 내로 원인 특정이 가능합니다. 다음에 같은 문제가 재발하지 않도록, redirect_uri 고정/PKCE 저장소/콜백 멱등성(1회 처리)을 설계 레벨에서 넣어두는 것을 권장합니다.