- Published on
OAuth redirect_uri 불일치·루프 10분 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 로그인(OAuth/OIDC)을 붙인 뒤 갑자기 redirect_uri mismatch가 뜨거나, 로그인 버튼을 누르면 IdP(구글/깃허브/카카오 등)와 우리 서비스 사이를 무한히 왕복하는 “로그인 루프”가 생기는 경우가 많습니다. 원인은 대개 단순합니다. 클라이언트가 실제로 요청한 redirect_uri와 IdP에 등록된 redirect URI, 그리고 서버가 생성/검증하는 redirect_uri가 미세하게라도 다르면(스킴/호스트/포트/슬래시/인코딩) 바로 깨집니다.
이 글은 “10분 안에” 문제를 끝내기 위한 실전 체크리스트입니다. 특히 ALB/Nginx/Ingress 같은 프록시 뒤에서 scheme(http/https)·host·path가 바뀌는 환경에서 자주 발생하는 케이스를 중심으로 설명합니다.
> 프록시/로드밸런서 환경에서 502/504나 헬스체크 문제도 같이 겪고 있다면, 네트워크 계층 이슈를 먼저 정리하는 게 빠릅니다: AWS ALB 502·504 난사 - 원인별 해결 체크리스트
증상 패턴: “불일치”와 “루프”는 어떻게 다르게 보이나
1) redirect_uri mismatch (대개 400)
- IdP 화면/에러 페이지에 다음과 유사한 메시지
redirect_uri_mismatchThe redirect URI in the request does not match the ones authorized for the OAuth client.
- 백엔드 로그에는 보통 콜백 엔드포인트가 아예 안 찍히거나, IdP에서 콜백을 거부해서 우리 서버까지 오지 않음
2) 로그인 루프 (성공처럼 보이지만 계속 다시 로그인)
- IdP 인증은 통과하는데 서비스로 돌아오면 다시
/login으로 리다이렉트 - 흔한 원인
- 세션/쿠키가 저장되지 않음(
SameSite,Secure, 도메인) state검증 실패(서버가 다른 state를 기대)- 콜백 처리 후 “원래 페이지”로 보내는 로직이 잘못되어 다시 로그인 시작
- 세션/쿠키가 저장되지 않음(
10분 해결 체크리스트 (가장 흔한 순서대로)
1) “실제 요청된 redirect_uri”를 먼저 캡처한다
가장 빠른 방법은 브라우저 네트워크 탭에서 IdP로 나가는 authorize 요청 URL을 복사하는 것입니다.
- 예:
https://accounts.google.com/o/oauth2/v2/auth?...&redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback&...
여기서 디코딩한 redirect_uri를 뽑아냅니다.
python - <<'PY'
import urllib.parse
u = 'https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback'
print(urllib.parse.unquote(u))
PY
이 값이 곧 “클라이언트가 IdP에게 약속한 콜백 주소”입니다. 이제 이 값과 IdP 콘솔(등록된 redirect URI)을 1:1로 비교하면 됩니다.
2) 스킴(http/https) 불일치: 프록시 뒤에서 가장 흔함
- 사용자는
https://app.example.com으로 접속 - 하지만 백엔드(앱)는 내부에서
http://로 인식 - 결과: 앱이 authorize URL을 만들 때
redirect_uri=http://app.example.com/callback을 만들어서 mismatch
해결 포인트
- 프록시가
X-Forwarded-Proto: https를 넣고 - 앱 프레임워크가 이를 “신뢰”하도록 설정해야 합니다.
(예) Express + Passport에서 trust proxy
import express from 'express';
const app = express();
app.set('trust proxy', true); // X-Forwarded-* 신뢰
// 이후 redirect_uri 생성 시 req.protocol이 https로 잡히게 됨
(예) Django에서 SECURE_PROXY_SSL_HEADER
# settings.py
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
USE_X_FORWARDED_HOST = True
(예) Spring Boot (ForwardedHeaderFilter)
@Bean
public FilterRegistrationBean<ForwardedHeaderFilter> forwardedHeaderFilter() {
FilterRegistrationBean<ForwardedHeaderFilter> bean = new FilterRegistrationBean<>(new ForwardedHeaderFilter());
bean.setOrder(0);
return bean;
}
> ALB/Ingress 앞단에서 헤더 전달이 꼬이거나 업스트림 타임아웃이 섞이면 증상이 복잡해질 수 있습니다. ALB 계층을 함께 점검하세요: AWS ALB 502·504 난사 - 원인별 해결 체크리스트
3) 호스트/포트 불일치: www, 포트, 내부 도메인
IdP는 redirect_uri를 문자열로 정확히 매칭합니다.
자주 틀리는 케이스:
https://example.com/callbackvshttps://www.example.com/callbackhttps://example.com:443/callback(명시 포트) vshttps://example.com/callback- 내부 도메인으로 생성됨:
http://service.namespace.svc.cluster.local/callback
해결 포인트
- 외부 공개 호스트를 단일화(예: www로 리다이렉트)하거나
- 애플리케이션이 redirect_uri를 만들 때 고정된 public base URL을 사용
(예) 환경변수로 public base url 고정
PUBLIC_BASE_URL=https://app.example.com
OAUTH_REDIRECT_PATH=/oauth/callback
// Node 예시
const redirectUri = `${process.env.PUBLIC_BASE_URL}${process.env.OAUTH_REDIRECT_PATH}`;
4) 경로(path)·슬래시·대소문자: “/callback” vs “/callback/”
IdP에 /oauth/callback로 등록했는데 요청은 /oauth/callback/로 나가면 mismatch입니다.
- 프레임워크 라우팅이 trailing slash를 강제로 붙이거나 제거하는 경우
- Ingress/Nginx rewrite로 path가 바뀌는 경우
해결 포인트
- IdP 등록값과 실제 요청값을 완전히 동일하게 맞추기
- rewrite를 쓴다면 콜백 경로만큼은 고정
(예) Nginx에서 콜백 경로는 rewrite 금지
location = /oauth/callback {
proxy_pass http://app_upstream;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
# 다른 경로는 rewrite 가능
proxy_pass http://app_upstream;
}
5) 쿼리스트링/인코딩: 이중 인코딩, 파라미터 순서
redirect_uri는 URL 인코딩이 들어갑니다. 다음이 흔한 함정입니다.
- 이미 인코딩된 redirect_uri를 다시 인코딩(이중 인코딩)
- redirect_uri에 불필요한 쿼리 파라미터를 붙임
점검 방법
- authorize 요청의
redirect_uri를 디코딩했을 때 정확히 한 번만 디코딩되어야 함
import urllib.parse
raw = 'https%253A%252F%252Fapp.example.com%252Foauth%252Fcallback'
print('1st:', urllib.parse.unquote(raw))
print('2nd:', urllib.parse.unquote(urllib.parse.unquote(raw)))
# 2nd에서야 정상 URL이 나오면 이중 인코딩 가능성
6) 로그인 루프의 1순위: 쿠키 SameSite/Secure/Domain
redirect_uri mismatch가 아니라 “루프”라면, 콜백까지는 오는데 세션이 유지되지 않는 경우가 많습니다.
대표적인 패턴:
/login에서state를 세션에 저장- IdP 인증 후
/oauth/callback으로 돌아옴 - 그런데 콜백 요청에 세션 쿠키가 안 실려서
state를 못 찾음 → 다시/login으로
해결 포인트
- HTTPS 환경이면 쿠키에
Secure필요 - 크로스사이트 리다이렉트가 포함되면
SameSite=Lax또는 상황에 따라None; Secure - 도메인 범위가 올바른지 확인(
Domain=.example.com등)
(예) Express 세션 쿠키 설정
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true, // HTTPS 필수
sameSite: 'lax', // 일반적인 OAuth 리다이렉트에 무난
// domain: '.example.com', // 서브도메인 공유 필요 시
}
}));
(예) Django 세션/CSRF 쿠키
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_SAMESITE = 'Lax'
7) state/nonce 검증 실패: 서버가 “다른 서버”가 됨(스케일아웃)
여러 인스턴스로 스케일아웃된 환경에서 세션 저장소가 로컬 메모리면, /login을 처리한 서버 A와 /callback을 처리한 서버 B가 달라져 state가 사라집니다. 그 결과 루프가 납니다.
해결 포인트
- 세션 스토어를 Redis 등 외부로
- 혹은 ALB sticky session(임시방편)
(예) Express + Redis 세션 스토어
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: true, sameSite: 'lax' }
}));
빠른 진단용 “3줄 비교표”
아래 3개를 한 화면에 놓고 완전 일치하는지 보면 대부분 끝납니다.
- 브라우저에서 캡처한 authorize 요청의
redirect_uri(디코딩) - IdP 콘솔에 등록된 Redirect URI
- 백엔드가 “내부적으로” 알고 있는 public base URL(로그로 출력)
백엔드에서 아래처럼 로그를 한 번 찍어두면 다음 장애 때 시간이 크게 줄어듭니다.
// Node/Express 예시
app.get('/login', (req, res) => {
const proto = req.headers['x-forwarded-proto'] || req.protocol;
const host = req.headers['x-forwarded-host'] || req.get('host');
const computedBase = `${proto}://${host}`;
console.log({ computedBase, url: req.originalUrl });
res.redirect('/start-oauth');
});
프록시/Ingress 환경에서의 “마지막 함정” 3가지
1) Ingress가 Host 헤더를 바꿔치기
Ingress/Nginx가 업스트림으로 보낼 때 Host를 내부 서비스명으로 바꾸면, 앱이 그 Host로 redirect_uri를 만들 수 있습니다. proxy_set_header Host $host;가 필요합니다.
2) HTTP→HTTPS 리다이렉트가 중간에 끼어든다
authorize 요청은 https://.../auth?redirect_uri=http://.../callback 같은 형태인데, callback은 다시 https로 강제 리다이렉트되면서 state가 깨지는 경우가 있습니다. 처음부터 redirect_uri를 https로 생성하도록 맞추는 게 정석입니다.
3) readiness/health 체크 경로와 인증 미들웨어 충돌
인증 미들웨어가 모든 경로를 /login으로 보내면, 헬스체크도 로그인으로 튀고, 그 과정에서 설정이 꼬여 루프처럼 보일 때가 있습니다. 쿠버네티스 환경이라면 readiness 경로는 인증 제외가 안전합니다.
관련해서 “로그는 정상인데 readiness만 실패” 같은 케이스는 아래 글의 점검 흐름이 도움이 됩니다: EKS에서 Readiness 실패인데 로그는 정상일 때
결론: 10분 안에 끝내는 우선순위
- **mismatch(400)**면: (1) 브라우저에서 redirect_uri 캡처 → (2) IdP 등록값과 1:1 비교 → (3) 프록시의
X-Forwarded-Proto/Host신뢰 설정 - 루프면: (1) 콜백까지 들어오는지 확인 → (2) 쿠키
Secure/SameSite/Domain점검 → (3) 스케일아웃이면 세션 스토어 외부화/스티키
OAuth는 복잡해 보이지만, redirect_uri는 결국 “문자열 완전 일치 게임”입니다. 실제 요청값을 먼저 확보하고(캡처), 프록시가 바꿔놓는 스킴/호스트/경로를 통제하면 대부분 그 자리에서 해결됩니다.