- Published on
Auth0 OAuth PKCE invalid_grant 원인과 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 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/token에code와code_verifier를 제출 - Auth0가 검증 실패 시
invalid_grant
즉, invalid_grant는 대개 토큰 교환 단계에서 발생하며, 원인은 크게 5가지 축으로 나뉩니다.
code_verifier불일치(저장/전달 문제 포함)redirect_uri불일치- 인가 코드 재사용 또는 만료
- 잘못된 클라이언트/테넌트/도메인으로 교환 요청
- 앱의 라우팅/세션/스토리지 정책(특히 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 불일치
전형적인 증상
- 로컬에서는 되는데 스테이징/프로덕션에서만 실패
- 특정 경로에서만 실패(예:
/callbackvs/auth/callback)
왜 발생하나
OAuth에서 redirect_uri는 보안상 매우 엄격합니다.
/authorize요청 시의redirect_uri/oauth/token요청 시의redirect_uri
이 둘이 완전히 동일해야 하는 구현/설정 조합이 많습니다. (라이브러리마다 다르지만, 안전하게는 동일하게 맞추는 것이 정석입니다.)
특히 아래가 흔합니다.
- 트레일링 슬래시 차이:
https://app.example.com/callbackvshttps://app.example.com/callback/ - 스킴 차이:
httpvshttps - 포트 차이:
http://localhost:3000/callbackvshttp://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를 확인해 아래를 비교합니다.
codecode_verifierredirect_urigrant_type이authorization_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_challenge는S256로 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개
/authorize와/oauth/token이 같은 Auth0 도메인(커스텀 도메인 포함)을 사용- 콜백 URL을 환경별로 단일화하고 Allowed Callback URLs와 정확히 일치
- 콜백 라우트는 인증 가드에서 예외 처리
- 콜백 처리 로직은 1회 실행 보장(Strict Mode, 중복 마운트 방지)
- 처리 후 URL에서
code/state제거 - 동시 로그인 시도(여러 탭) 시 트랜잭션 저장소 충돌 방지
- Safari/iOS/WebView에서 스토리지 정책 검증(프라이빗 모드 포함)
- 클록 스큐가 큰 환경(가상화/컨테이너)이라면 시간 동기화 점검
- 프록시 뒤라면 원본 스킴/호스트가 유지되도록
X-Forwarded-*처리 - 네트워크 재시도 로직이 토큰 교환을 중복 호출하지 않게 설계
- Auth0 로그에서 실패 이벤트의 상세 필드를 수집/알림
- 가능하면 공식 SDK 사용으로 트랜잭션 관리 위임
인증 플로우에서 “무한 리다이렉트”나 “콜백 처리 중복”은 invalid_grant로도 나타나곤 합니다. 비슷한 증상 패턴을 다룬 글도 함께 보면 원인 분해에 도움이 됩니다.
결론
Auth0 PKCE의 invalid_grant는 대개 서버가 나쁘다기보다, 클라이언트가 들고 있어야 할 트랜잭션 상태(code_verifier, state, redirect_uri)가 리다이렉트 과정에서 깨졌다는 신호입니다.
가장 빠른 해결 루트는 다음 순서입니다.
- DevTools에서
/oauth/token요청 payload 확인(code_verifier,redirect_uri) - Auth0 Logs에서 실패 이벤트 상세 확인
- 콜백 처리 중복 실행/재시도/새로고침으로 코드 재사용이 있는지 확인
- 도메인/리다이렉트 URI/커스텀 도메인 혼용 여부 정리
- 스토리지 정책이 까다로운 브라우저(iOS/Safari/WebView)에서 재현 테스트
이 과정을 통과하면 invalid_grant는 대부분 재현 가능하고, 재현 가능하면 해결도 가능합니다.