- Published on
OAuth 콜백 400/redirect_uri 불일치 10분 진단
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 로그인(OAuth 2.0 / OIDC)을 붙이다 보면, 가장 자주 그리고 가장 빨리 팀의 시간을 갉아먹는 에러가 있습니다.
- 콜백에서
400 Bad Request - 에러 메시지에
redirect_uri mismatch혹은The redirect URI is not registered류
문제는 단순해 보이지만, 실제 원인은 프록시/HTTPS 종단/인코딩/환경 설정 등으로 갈라져서 디버깅이 길어지기 쉽습니다. 이 글은 “10분 안에” 원인을 좁히는 순서로 정리합니다.
> 운영 환경에서 프록시/Ingress가 개입되는 경우가 많습니다. ALB/Ingress 이슈로 요청이 변형되는 패턴은 EKS ALB Ingress 502 target timeout 원인·해결에서도 자주 등장하는데, OAuth에서도 비슷하게 원본 스킴/호스트가 바뀌는 문제가 핵심입니다.
0) 증상 정의: “불일치”는 무엇과 무엇이 다른가
redirect_uri mismatch는 보통 아래 두 값이 문자열로 완전 일치하지 않아서 발생합니다.
- Authorization Request에 포함된
redirect_uri - IdP(구글/깃허브/카카오/애플/Keycloak/Auth0 등)에 등록된 Redirect URI
대부분의 IdP는 다음을 엄격히 봅니다.
- 스킴(
httpvshttps) - 호스트(
app.example.comvsexample.com) - 포트(
:3000,:443포함 여부) - 경로(
/oauth/callbackvs/oauth/callback/) - 쿼리스트링 포함 여부(허용/불허는 IdP마다 다름)
- URL 인코딩 결과(특히
:/?&)
즉, “대충 비슷한 URL”은 통하지 않습니다.
1) 1분: 실제로 IdP에 전달된 redirect_uri를 캡처
브라우저에서 가장 빠른 방법
- 크롬 DevTools → Network → 로그인 버튼 클릭 → IdP로 이동하는 요청(대개
authorize) 선택 - Query String Parameters에서
redirect_uri확인
혹은 주소창에 보이는 authorize URL에서 redirect_uri= 뒤를 그대로 복사합니다.
서버 로그에서 확인(백엔드가 authorize URL을 생성하는 경우)
백엔드가 authorize URL을 만들어 프론트에 전달한다면, 해당 시점에 로그를 찍어두는 게 가장 확실합니다.
// Node/Express 예시: authorize URL 생성 직후
console.log('authorize redirect_uri:', redirectUri);
console.log('authorize url:', authorizeUrl);
여기서 얻어야 하는 것:
- 최종
redirect_uri문자열(인코딩 전/후) - 실제 authorize URL 전체
2) 2분: IdP 콘솔에 등록된 Redirect URI와 “문자열 비교”
이 단계에서 70%는 끝납니다.
- 등록된 값과 완전히 동일한지 비교
- 특히 아래 4개를 눈으로 체크
http/httpswww포함 여부- trailing slash(
/callbackvs/callback/) - 포트 포함 여부
흔한 함정 1: trailing slash
- 등록:
https://app.example.com/oauth/callback - 실제:
https://app.example.com/oauth/callback/
IdP는 보통 다른 URL로 취급합니다.
흔한 함정 2: 포트
- 로컬:
http://localhost:3000/oauth/callback - 등록:
http://localhost/oauth/callback
로컬 개발 중이면 포트까지 맞춰야 합니다.
3) 2분: HTTPS 종단(프록시/Ingress/ALB) 때문에 스킴이 바뀌는지 확인
운영에서 가장 흔한 케이스입니다.
- 외부:
https://app.example.com(ALB/Ingress에서 TLS 종료) - 내부 앱:
http://app:8080
이때 앱이 요청을 보고 redirect_uri를 조립하면 http://...로 만들어 버립니다. 결과적으로 IdP에 등록된 https://...와 불일치.
체크 포인트
- 앱이
redirect_uri를 요청 기반으로 동적 생성하는가? X-Forwarded-Proto,X-Forwarded-Host를 신뢰하도록 설정했는가?
Express에서의 대표 해결(프록시 신뢰)
import express from 'express';
const app = express();
// ALB/Nginx/Ingress 뒤라면 필수인 경우가 많음
app.set('trust proxy', true);
app.get('/login', (req, res) => {
// req.protocol이 https로 잡히도록
const redirectUri = `${req.protocol}://${req.get('host')}/oauth/callback`;
res.send({ redirectUri });
});
Nginx/Ingress에서 X-Forwarded-* 헤더가 제대로 전달되는지도 확인해야 합니다.
> Ingress/ALB가 개입된 장애는 재현이 어려워 로그/헤더 확인이 핵심입니다. 네트워크 계층에서 요청이 어떻게 바뀌는지 추적하는 접근은 systemd 서비스 재시작 루프 10분 진단 가이드처럼 “관측→가설→검증” 루틴이 가장 빠릅니다.
4) 2분: redirect_uri 인코딩/디코딩 이중 적용 여부
redirect_uri는 보통 authorize URL의 쿼리 파라미터로 들어가므로 URL 인코딩이 필요합니다. 문제는 다음 두 가지가 섞일 때 발생합니다.
- 한 번 인코딩해야 하는데 안 함
- 이미 인코딩된 값을 다시 인코딩(이중 인코딩)
정상 예시
- 원문:
https://app.example.com/oauth/callback - 인코딩:
https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback
이중 인코딩 예시(문제)
- 1차 인코딩:
https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback - 2차 인코딩:
https%253A%252F%252Fapp.example.com%252Foauth%252Fcallback
IdP는 보통 최종적으로 디코딩한 값이 등록 값과 일치해야 하는데, 구현에 따라 불일치로 떨어집니다.
Node에서 안전한 조립 패턴
const base = 'https://idp.example.com/oauth/authorize';
const redirectUri = 'https://app.example.com/oauth/callback';
const url = new URL(base);
url.searchParams.set('client_id', process.env.CLIENT_ID);
url.searchParams.set('response_type', 'code');
url.searchParams.set('redirect_uri', redirectUri); // URLSearchParams가 인코딩 처리
url.searchParams.set('scope', 'openid profile email');
console.log(url.toString());
- 문자열 더하기로
...?redirect_uri=${encodeURIComponent(redirectUri)}를 하다가 - 나중에 다른 레이어에서 또 인코딩하는 실수가 잦습니다.
5) 2분: “정확히 같은 redirect_uri”를 토큰 교환 단계에서도 쓰는지 확인
일부 OAuth 클라이언트/서버 구현은 토큰 교환(token endpoint) 요청에서도 redirect_uri를 함께 보내며, 이 값이 authorize 때 사용한 값과 완전 일치해야 합니다.
- authorize 요청:
redirect_uri=A - token 요청:
redirect_uri=B
이면 IdP가 invalid_grant 혹은 redirect_uri mismatch로 거절합니다.
예: token 교환에서 redirect_uri 누락/불일치
curl -X POST https://idp.example.com/oauth/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=authorization_code' \
-d 'client_id=...' \
-d 'client_secret=...' \
-d 'code=AUTH_CODE' \
-d 'redirect_uri=https://app.example.com/oauth/callback'
진단 팁: authorize 단계에서 사용한 redirect_uri를 서버 세션/상태에 저장해 두고, token 교환에서도 동일 값을 재사용하면 안전합니다.
6) 1분: 환경별 설정(DEV/STG/PROD) 혼선 제거
redirect_uri mismatch는 “설정이 틀렸다”기보다 “환경이 섞였다”인 경우가 많습니다.
- 프론트는 PROD 도메인으로 authorize 요청
- 백엔드는 STG redirect_uri로 token 교환
- IdP에는 PROD만 등록
체크리스트
CLIENT_ID/CLIENT_SECRET이 환경에 맞는가- 프론트의
NEXT_PUBLIC_BASE_URL(또는 앱 base URL)이 올바른가 - 백엔드의
APP_BASE_URL(또는 callback base)가 올바른가
예: 환경변수로 base URL을 고정(동적 조립 최소화)
// TypeScript 예시
const APP_BASE_URL = process.env.APP_BASE_URL; // 예: https://app.example.com
if (!APP_BASE_URL) throw new Error('APP_BASE_URL is required');
export const REDIRECT_URI = `${APP_BASE_URL}/oauth/callback`;
동적 조립(요청 기반)은 프록시/헤더 신뢰 문제를 계속 유발하므로, 가능하면 환경변수로 고정하는 편이 운영에서 덜 아픕니다.
7) 자주 나오는 케이스별 “즉시 수정” 처방
케이스 A) 로컬 개발: http/https 혼재
- 로컬은
http://localhost:3000/callback - IdP 설정에서 로컬 redirect URI를 별도로 추가
- 가능하면 로컬용 OAuth 앱(클라이언트)을 따로 만들기
케이스 B) 서브도메인/WWW 차이
example.com과www.example.com은 다릅니다.- 한 쪽으로 통일하거나, IdP에 둘 다 등록(가능한 경우)
케이스 C) 경로가 라우터에서 리다이렉트됨
예: /oauth/callback로 와야 하는데 앱이 /login/callback로 301/302를 걸어버림.
- IdP는 리다이렉트 체인을 따라가 주지 않는 경우가 많습니다.
- 콜백 엔드포인트는 “정확한 경로”로 직접 처리하세요.
케이스 D) 쿼리스트링 포함 redirect_uri
어떤 IdP는 redirect_uri에 쿼리를 허용하지 않거나, 등록 시 쿼리까지 동일해야 합니다.
- 가능하면 콜백 URL은 쿼리 없이 고정
- 추가 정보는
state파라미터에 넣고 서명/검증
8) 10분 내 결론을 내기 위한 최소 로그/관측 포인트
운영에서 재현이 어렵다면, 아래 3가지만 남겨도 원인 파악 속도가 급상승합니다.
- authorize 요청 생성 시점의
redirect_uri(인코딩 전/후) - 콜백 엔드포인트로 들어온 요청의
Host,X-Forwarded-Proto,X-Forwarded-Host - token 교환 시점의
redirect_uri
Express 콜백에서 헤더를 빠르게 덤프하는 예:
app.get('/oauth/callback', (req, res) => {
console.log('callback host:', req.get('host'));
console.log('x-forwarded-proto:', req.get('x-forwarded-proto'));
console.log('x-forwarded-host:', req.get('x-forwarded-host'));
console.log('full url:', `${req.protocol}://${req.get('host')}${req.originalUrl}`);
res.send('ok');
});
마무리: redirect_uri는 “문자열 동일성” 게임이다
redirect_uri mismatch는 결국 IdP에 등록된 문자열과 실제로 전송된 문자열이 1바이트라도 다르면 실패하는 문제입니다. 그래서 감으로 수정하기보다,
- 실제 전송값 캡처 →
- 등록값과 문자 단위 비교 →
- 프록시/인코딩/환경 혼선을 제거
이 순서로 가면 10분 안에 끝낼 확률이 높습니다.
운영에서 특히 많이 터지는 지점은 “TLS 종료 뒤에서 앱이 http로 인식”하는 케이스이니, X-Forwarded-*와 프레임워크의 프록시 신뢰 설정을 먼저 의심해 보세요.