- Published on
Auth0 OAuth 400 invalid_grant - PKCE·redirect_uri 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
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/callbackvshttp://localhost:3000/callback/http://localhost:3000/callbackvshttp://127.0.0.1:3000/callbackhttps://app.example.com/callbackvshttps://app.example.com/callback?foo=bar- 보통 토큰 교환의 redirect_uri에는 쿼리를 붙이지 않거나, 붙이면 authorize 때와 완전히 같아야 합니다.
- 프록시/로드밸런서 뒤에서 외부는 https인데 내부에서 http로 redirect_uri를 구성
해결 체크리스트
- Auth0 대시보드 → Application → Settings
- Allowed Callback URLs에 실제 콜백 URL을 정확히 등록
/authorize요청의redirect_uri와/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생성 /authorize에code_challenge를 보냄/oauth/token에 원본code_verifier를 보냄- 서버가 검증:
transform(code_verifier) == code_challenge
따라서 아래 중 하나라도 어긋나면 invalid_grant가 납니다.
- code_verifier를 저장하지 못함(페이지 리로드/새 탭/스토리지 초기화)
- 서로 다른 인스턴스(서버/클라이언트)에서 verifier를 생성
- base64url 인코딩 규칙을 잘못 적용
code_challenge_method를S256로 보냈는데 실제는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개
- redirect_uri를 상수로 고정
- 프론트/백엔드가 각각 “추측해서 조립”하지 않게 만들기
- PKCE verifier 저장소를 명확히
- SPA는 sessionStorage 권장(탭 단위)
- 콜백 처리 전에 초기화/로그아웃 로직이 실행되지 않게 순서 조정
- 콜백 처리 멱등성 확보
- code 1회 처리 가드 + URL에서 code 제거
- StrictMode/이펙트 중복 실행 방지
- 개발 환경에서만 발생하는 중복 교환을 제거
- 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회 처리)을 설계 레벨에서 넣어두는 것을 권장합니다.