- Published on
OAuth2 PKCE redirect_uri 불일치 401 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 OAuth2 로그인을 PKCE로 붙이다 보면, 인증 화면까지는 잘 갔다가 token 엔드포인트에서 갑자기 401 혹은 invalid_grant로 떨어지는 경우가 있습니다. 이때 로그를 더 파보면 높은 확률로 핵심 원인은 하나입니다. redirect_uri가 인가 요청(authorization request)과 토큰 교환(token request)에서 1바이트라도 다르게 들어갔다는 것.
PKCE는 code_verifier 검증이 주인공처럼 보이지만, 대부분의 OAuth 서버는 보안상 authorization_code를 발급할 때 사용된 redirect_uri를 함께 저장해두고, 토큰 교환 시 동일한 redirect_uri가 오지 않으면 교환을 거부합니다. 이 글은 그 “동일성”이 생각보다 까다롭다는 점(스킴, 호스트, 포트, 경로, 트레일링 슬래시, 인코딩, 프록시 헤더)을 중심으로 401을 끝내는 방법을 다룹니다.
프록시나 로드밸런서 뒤에서 콜백이 꼬이는 케이스는 아래 글도 같이 보면 원인 파악이 빨라집니다.
증상 패턴: “로그인은 됐는데 토큰 교환에서 401”
대표적인 증상은 다음 중 하나로 나타납니다.
- 토큰 엔드포인트 응답이
401 Unauthorized - 응답 바디에
invalid_grant,redirect_uri mismatch,unauthorized_client등 - IdP 콘솔 로그에 “redirect URI does not match”
- 개발 환경에서는 되는데 스테이징/프로덕션에서만 실패
중요 포인트는, 인가 코드 발급 단계는 성공한다는 겁니다. 즉, 사용자는 로그인하고 동의하고, 애플리케이션은 code를 잘 받습니다. 실패는 그 다음 단계인 code를 access_token으로 바꾸는 요청에서 발생합니다.
원리: 왜 PKCE에서도 redirect_uri가 같아야 하나
OAuth2 Authorization Code 플로우에서 서버는 대개 이런 식으로 상태를 저장합니다.
code값client_id- 사용자가 승인한 스코프
- 발급 시점
- (PKCE)
code_challenge및 방식 - 발급 당시의
redirect_uri
토큰 교환 요청은 보통 아래 파라미터를 포함합니다.
grant_type=authorization_codecode=...client_id=...- (PKCE)
code_verifier=... redirect_uri=...
여기서 OAuth 서버는 “이 code는 원래 이 redirect_uri로 리다이렉트할 때 발급된 건데, 지금 토큰으로 바꾸려는 요청이 다른 redirect_uri를 들고 왔네”라고 판단하면 공격(코드 탈취 후 다른 리다이렉트로 교환)을 막기 위해 교환을 거부합니다.
즉 결론은 단순합니다.
- 인가 요청의
redirect_uri와 토큰 요청의redirect_uri는 완전히 동일해야 한다
문제는 “완전히 동일”의 범위가 체감보다 넓다는 점입니다.
redirect_uri 불일치가 생기는 8가지 흔한 원인
1) HTTP와 HTTPS 스킴이 달라짐
가장 흔합니다. 로컬에서는 http://localhost:3000/callback을 쓰다가, 배포 환경에서는 외부는 HTTPS인데 내부 앱은 HTTP로 동작하며 앱이 자기 자신을 http://로 인식하는 경우입니다.
- 인가 요청:
https://app.example.com/oauth/callback - 토큰 요청:
http://app.example.com/oauth/callback
프록시 뒤에서 X-Forwarded-Proto를 앱이 신뢰하지 않으면 이런 일이 생깁니다.
2) 포트가 붙거나 빠짐
https://app.example.com/callbackhttps://app.example.com:443/callback
일부 IdP는 이를 다르게 봅니다. 특히 커스텀 구현 또는 엄격한 비교 로직에서 문제를 일으킵니다.
3) 트레일링 슬래시 차이
https://app.example.com/oauth/callbackhttps://app.example.com/oauth/callback/
서버 입장에서는 문자열이 다르므로 불일치입니다.
4) 쿼리스트링 포함 여부
https://app.example.com/oauth/callbackhttps://app.example.com/oauth/callback?source=google
IdP에 등록한 리다이렉트 URI 템플릿이 “정확히 일치” 정책이면 바로 실패합니다.
5) URL 인코딩 차이
브라우저 리다이렉트나 라이브러리에서 인코딩을 다르게 적용해,
- 인가 요청의
redirect_uri는 한 번 인코딩 - 토큰 요청의
redirect_uri는 두 번 인코딩
같은 문제가 생길 수 있습니다.
예를 들어 redirect_uri 자체가 쿼리를 포함하면(권장하진 않지만) 인코딩 차이가 더 자주 발생합니다.
6) 콜백 경로가 환경별로 다름
- 개발:
/api/auth/callback/provider - 운영:
/auth/callback/provider
Next.js나 프레임워크의 라우팅/리라이트 설정에 따라, 외부에서 보이는 경로와 내부 핸들러 경로가 달라질 수 있습니다.
7) IdP 콘솔에 등록된 redirect URI와 실제가 다름
토큰 교환 불일치 이전에, 사실은 등록값이 틀린데 인가 단계는 통과하는 IdP도 있습니다(특정 설정 조합, 와일드카드 허용, 여러 URI 중 선택 등). 결국 토큰 단계에서 엄격 비교로 걸리는 식입니다.
8) 라이브러리가 인가 요청과 토큰 요청에서 redirect_uri를 다르게 구성
예를 들어 인가 URL 생성 시에는 NEXTAUTH_URL을 쓰고, 토큰 교환 시에는 런타임의 Host 헤더 기반으로 조립한다면, 프록시/도메인 별칭에서 불일치가 발생합니다.
재현 가능한 진단법: “두 요청의 redirect_uri를 그대로 비교”
해결의 핵심은 감으로 맞추는 게 아니라, 인가 요청과 토큰 요청에서 실제로 전송된 redirect_uri를 정확히 캡처해서 비교하는 것입니다.
1) 브라우저에서 인가 요청의 redirect_uri 확인
브라우저 개발자 도구 Network 탭에서 IdP로 향하는 authorize 요청을 찾고, 쿼리 파라미터의 redirect_uri를 확인합니다.
인가 요청 예시는 보통 이런 형태입니다.
GET https://idp.example.com/oauth2/authorize?response_type=code&client_id=...&redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback&code_challenge=...&code_challenge_method=S256&state=...
여기서 redirect_uri를 디코딩한 값을 메모해 둡니다.
2) 서버에서 토큰 요청의 redirect_uri 로깅
백엔드가 토큰 엔드포인트로 요청을 보낼 때, 폼 바디에 들어가는 redirect_uri를 그대로 로그로 남기세요. (운영에서는 민감정보 제외)
Node.js에서 application/x-www-form-urlencoded로 토큰 요청을 보내는 예시입니다.
import fetch from "node-fetch";
export async function exchangeToken({ code, codeVerifier }) {
const redirectUri = process.env.OAUTH_REDIRECT_URI;
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: process.env.OAUTH_CLIENT_ID,
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
});
// 진단용: redirect_uri가 실제로 무엇인지 확인
console.log("token redirect_uri:", redirectUri);
const res = await fetch(process.env.OAUTH_TOKEN_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body,
});
const text = await res.text();
if (!res.ok) {
throw new Error(`token exchange failed: ${res.status} ${text}`);
}
return JSON.parse(text);
}
이 로그의 redirectUri가, 1번에서 확인한 인가 요청의 디코딩 값과 문자열로 완전히 동일해야 합니다.
3) 가능하면 IdP 로그에서 mismatch 메시지 확인
Okta, Auth0, Azure AD 등은 이벤트 로그에 mismatch 이유를 남기는 경우가 많습니다. “Expected redirect URI”와 “Actual redirect URI”가 같이 보이면 게임 끝입니다.
해결 체크리스트: 401 redirect_uri mismatch를 끝내는 순서
1) redirect_uri를 “한 곳”에서만 만들고, 두 단계에서 재사용
인가 URL을 만들 때와 토큰 교환 때 각각 redirect_uri를 조립하지 말고, 단 하나의 설정값으로 고정하세요.
OAUTH_REDIRECT_URI=https://app.example.com/oauth/callback
그리고 인가 URL 생성과 토큰 요청이 이 값을 그대로 사용하도록 통일합니다.
const REDIRECT_URI = process.env.OAUTH_REDIRECT_URI;
export function buildAuthorizeUrl() {
const u = new URL(process.env.OAUTH_AUTHORIZE_ENDPOINT);
u.searchParams.set("response_type", "code");
u.searchParams.set("client_id", process.env.OAUTH_CLIENT_ID);
u.searchParams.set("redirect_uri", REDIRECT_URI);
u.searchParams.set("code_challenge_method", "S256");
u.searchParams.set("code_challenge", "...computed...");
u.searchParams.set("state", "...state...");
return u.toString();
}
이렇게 하면 런타임 환경(프록시, Host 헤더, 포트)에 따라 값이 흔들릴 여지가 크게 줄어듭니다.
2) 프록시 뒤라면 “외부 기준 URL”을 앱에 알려주기
Next.js, Express, Spring 등 대부분의 프레임워크는 프록시 뒤에서 스킴을 잘못 인식할 수 있습니다.
- 프록시가
X-Forwarded-Proto: https를 넣어도 앱이 신뢰하지 않음 - 앱이
req.protocol을http로 계산 - 그 결과
redirect_uri를http://...로 생성
이 경우는 프록시/앱 설정으로 해결합니다. 자세한 설정 패턴은 아래 글이 실전적입니다.
핵심은 다음 중 하나입니다.
- 앱에서
trust proxy활성화 - 프록시에서
X-Forwarded-Proto,X-Forwarded-Host를 올바르게 전달 - 가능하면
redirect_uri를 조립하지 말고 환경변수로 고정
3) 트레일링 슬래시 정책을 정하고 강제
팀 단위로 흔들리는 지점입니다.
- IdP 등록값:
https://app.example.com/oauth/callback - 코드 상수도 동일하게
- 라우터 리다이렉트나 rewrite에서 뒤에
/를 붙이지 않게
필요하면 서버에서 들어오는 요청을 한쪽으로 리다이렉트해 정규화하되, OAuth 콜백 경로는 불필요한 301/302가 끼지 않게 주의합니다.
4) 포트 표기를 통일
외부에 노출되는 URL에 포트가 없다면 redirect_uri에도 포트를 넣지 않는 쪽이 안전합니다.
- 로컬 개발은
http://localhost:3000/callback처럼 포트가 필수 - 운영은
https://app.example.com/callback처럼 포트 생략
환경별로 OAUTH_REDIRECT_URI를 분리하고, 각 환경의 IdP 앱 설정에도 동일하게 등록합니다.
5) URL 인코딩은 “라이브러리에 맡기고, 중복 인코딩 금지”
인가 URL을 만들 때 redirect_uri를 직접 encodeURIComponent로 감싸고, 또 URL 빌더가 다시 인코딩하면 이중 인코딩이 됩니다.
권장 패턴은 다음 둘 중 하나입니다.
URL과searchParams를 사용해 인코딩을 맡김- 혹은 문자열로 직접 만들되 인코딩을 정확히 한 번만 수행
// 좋은 예: searchParams가 인코딩을 처리
const u = new URL("https://idp.example.com/oauth2/authorize");
u.searchParams.set("redirect_uri", "https://app.example.com/oauth/callback");
6) IdP 콘솔의 redirect URI 등록값을 “실제 요청값” 기준으로 재정렬
IdP에 여러 redirect URI를 등록해두면, 환경별로 어떤 값이 쓰이는지 혼란이 커집니다.
- 개발, 스테이징, 운영을 명확히 분리
- 가능하면 도메인을 분리
- 와일드카드는 최소화
그리고 반드시 “실제로 나가는 redirect_uri”를 기준으로 등록값을 맞춥니다. 코드에 맞추는 게 아니라, 네트워크에서 캡처한 값을 기준으로 맞추는 게 빠릅니다.
자주 놓치는 케이스: Next.js에서 base URL이 흔들리는 문제
Next.js 기반에서 특히 자주 보는 패턴입니다.
- 서버 컴포넌트/라우트 핸들러에서
headers()로 Host를 읽어 URL을 조립 - 프록시 환경에서 Host가 내부 도메인으로 들어옴
- 결과적으로 토큰 교환의
redirect_uri가 내부 도메인이 됨
이럴 때는 redirect_uri를 환경변수로 고정하는 것이 가장 단단합니다.
또한 배포 파이프라인에서 환경변수/캐시가 꼬여 오래된 값이 남아있는 경우도 있습니다. “분명 바꿨는데 계속 같은 redirect_uri가 나간다”면 캐시/빌드 산출물 문제를 의심해볼 만합니다.
실전 예시: Authorization 요청과 Token 요청을 같은 상수로 묶기
아래 예시는 프론트에서 authorize로 보내고, 백엔드에서 token 교환을 하는 전형적인 구조에서 redirect_uri를 단일 상수로 통일하는 방식입니다.
// config/oauth.js
export const OAUTH = {
clientId: process.env.OAUTH_CLIENT_ID,
authorizeEndpoint: process.env.OAUTH_AUTHORIZE_ENDPOINT,
tokenEndpoint: process.env.OAUTH_TOKEN_ENDPOINT,
redirectUri: process.env.OAUTH_REDIRECT_URI,
};
// authorize-url.js
import { OAUTH } from "./config/oauth.js";
export function makeAuthorizeUrl({ codeChallenge, state }) {
const u = new URL(OAUTH.authorizeEndpoint);
u.searchParams.set("response_type", "code");
u.searchParams.set("client_id", OAUTH.clientId);
u.searchParams.set("redirect_uri", OAUTH.redirectUri);
u.searchParams.set("code_challenge_method", "S256");
u.searchParams.set("code_challenge", codeChallenge);
u.searchParams.set("state", state);
return u.toString();
}
// token-exchange.js
import { OAUTH } from "./config/oauth.js";
export async function exchange({ code, codeVerifier }) {
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: OAUTH.clientId,
code,
redirect_uri: OAUTH.redirectUri,
code_verifier: codeVerifier,
});
const res = await fetch(OAUTH.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
const payload = await res.json().catch(async () => ({ raw: await res.text() }));
if (!res.ok) {
// redirect_uri mismatch는 여기서 가장 많이 드러남
throw new Error(`token error: ${res.status} ${JSON.stringify(payload)}`);
}
return payload;
}
이 구조의 장점은 명확합니다.
- “인가 단계의 redirect_uri”와 “토큰 단계의 redirect_uri”가 구조적으로 같아짐
- 프록시/Host 헤더에 덜 민감
- 환경별로
OAUTH_REDIRECT_URI만 바꾸면 됨
결론: 401은 PKCE 문제가 아니라 문자열 동일성 문제인 경우가 많다
OAuth2 PKCE에서 redirect_uri 불일치로 인한 401은, PKCE 자체가 어려워서가 아니라 두 단계에서 redirect URI를 만드는 주체가 달라서 생기는 경우가 대부분입니다.
정리하면 해결 공식은 이렇습니다.
- 네트워크에서 인가 요청의
redirect_uri를 캡처한다 - 토큰 요청의
redirect_uri를 로그로 캡처한다 - 둘을 문자열로 완전 일치시킨다
- 프록시 뒤라면 스킴/호스트 인식을 교정하거나, 아예
redirect_uri를 환경변수로 고정한다
이 과정을 거치면 code_verifier를 의심하며 시간을 쓰기 전에, 대다수의 401을 빠르게 종결할 수 있습니다.