Published on

Nginx 뒤 OAuth 콜백 302 무한리다이렉트 원인

Authors

서드파티 OAuth 로그인(Authorization Code Flow)을 붙인 서비스가 Nginx 뒤로 들어가는 순간, 잘 되던 콜백이 302를 반복하며 로그인 루프에 빠지는 일이 자주 발생합니다. 겉으로는 “로그인 버튼 → IdP(구글/깃허브/카카오) → 우리 서비스 콜백 → 다시 로그인 페이지”가 계속 반복됩니다.

이 글은 Nginx 뒤에서 OAuth 콜백이 302 무한 리다이렉트로 이어지는 전형적인 원인을 “요청 스킴/호스트 인식”과 “세션 쿠키 저장 실패” 관점에서 분해해 설명하고, Nginx·애플리케이션(특히 Spring/Node/Next.js)에서의 해결책을 코드로 정리합니다.

> 프론트(Next.js) 쪽에서 루프가 나는 케이스는 Next.js OAuth 로그인 무한 리다이렉트 해결 가이드도 함께 보면 원인 범위를 빠르게 좁힐 수 있습니다.

증상 패턴: 302가 “정상”처럼 보이는데 왜 무한 루프가 되나

OAuth의 코드 플로우는 본질적으로 리다이렉트를 여러 번 사용합니다.

  1. /login → IdP로 302
  2. IdP 인증 후 → /oauth/callback?code=...&state=...로 302
  3. 콜백에서 코드 교환 후 → / 또는 /app으로 302

문제는 2 또는 3 단계에서 세션이 유지되지 않거나, 앱이 URL을 잘못 생성하면 앱이 “아직 로그인 안 됨”으로 판단해 다시 1로 보내면서 루프가 시작된다는 점입니다.

루프의 핵심 원인은 보통 아래 두 축 중 하나입니다.

  • 앱이 외부에서 들어온 요청을 http로 오인(실제는 https) → redirect_uri/absolute URL 생성이 틀어짐
  • Set-Cookie가 브라우저에 저장되지 않음(Secure/SameSite/Domain/Path 문제) → 콜백 처리 후에도 세션이 없다고 판단

원인 1) X-Forwarded-Proto/Host 누락으로 스킴이 http로 인식됨

Nginx가 TLS를 종료하고(https는 Nginx에서 끝남) 백엔드로는 http로 프록시하는 구조에서, 백엔드는 기본적으로 요청 스킴을 http로 봅니다.

그 결과:

  • 백엔드가 redirect_uri=http://example.com/oauth/callback 같은 값을 생성
  • IdP에 등록된 redirect_uri는 https://example.com/oauth/callback
  • 혹은 콜백 이후 앱이 http://...로 302 → 브라우저/보안정책/프레임워크에서 다시 https로 올리거나, 다시 인증 플로우를 타면서 루프

Nginx에서 반드시 전달해야 하는 헤더

아래는 “외부 클라이언트가 보던 원본 정보”를 백엔드가 알 수 있게 하는 최소 세트입니다.

location / {
  proxy_pass http://app_upstream;

  proxy_set_header Host $host;
  proxy_set_header X-Real-IP $remote_addr;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Proto $scheme;
  proxy_set_header X-Forwarded-Host $host;
  proxy_set_header X-Forwarded-Port $server_port;
}
  • $scheme는 Nginx에 들어온 요청 기준입니다. 즉 https로 들어오면 https가 됩니다.
  • Host가 내부 호스트로 바뀌면(예: upstream 이름) OAuth redirect URI 생성이 틀어지기 쉽습니다.

Spring Boot에서 프록시 헤더 신뢰 설정

Spring 계열은 프록시 환경에서 헤더를 신뢰하도록 설정해야 “외부 스킴/호스트”를 올바르게 복원합니다.

application.yml

server:
  forward-headers-strategy: framework

또는 환경에 따라(특히 오래된 구성/서블릿 컨테이너) 아래가 필요할 수 있습니다.

server:
  tomcat:
    remoteip:
      protocol-header: X-Forwarded-Proto
      remote-ip-header: X-Forwarded-For

이 설정이 없으면, 컨트롤러에서 request.getScheme()http로 찍히고, Spring Security가 생성하는 redirect URL도 어긋날 수 있습니다.

> Cloudflare/다단 프록시에서 특히 http로 떨어지는 증상은 Cloudflare 뒤에서 OAuth 콜백이 http로 떨어질 때와 원인이 거의 같습니다. Nginx 앞단에 CDN/ALB가 하나 더 있으면 “어느 레이어에서 X-Forwarded-Proto가 깨지는지”부터 확인하세요.

원인 2) redirect_uri(또는 callback URL) 불일치

IdP는 보안상 redirect_uri의 정확한 일치(스킴/호스트/포트/패스)를 요구합니다. 프록시 뒤에서 아래처럼 미세하게 달라지면, 에러가 나거나(명확한 에러) 혹은 앱이 자체적으로 다시 로그인으로 보내며(불명확한 루프) 문제가 커집니다.

  • https://example.com/oauth/callback (등록값)
  • https://example.com/oauth/callback/ (슬래시 하나 차이)
  • https://www.example.com/oauth/callback vs https://example.com/oauth/callback
  • https://example.com:443/oauth/callback vs 포트 생략
  • 내부에서 http://app:8080/oauth/callback로 생성

진단 체크리스트

  • 브라우저 DevTools Network에서 IdP로 나가는 authorize 요청의 redirect_uri를 확인
  • 콜백으로 돌아왔을 때 서버 로그에 찍히는 Host/Proto가 기대값인지 확인
  • 애플리케이션이 “절대 URL”을 만들어내는 지점(프레임워크 설정/ENV)을 확인

원인 3) 세션 쿠키가 저장되지 않아 “로그인한 적이 없음”으로 반복

콜백에서 코드 교환은 성공했는데도 다시 로그인으로 튕긴다면, 대부분 세션 쿠키가 브라우저에 저장되지 않거나 다음 요청에 실리지 않기 때문입니다.

대표적인 원인:

  • Set-CookieSecure가 필요한데 http로 인식되어 빠짐
  • SameSite 정책 때문에 OAuth 리다이렉트 흐름에서 쿠키가 제외됨
  • Domain이 잘못되어 쿠키가 다른 도메인에만 저장됨
  • Path가 너무 좁아 /oauth/callback에만 붙고 /에는 안 붙음

SameSite/secure 기본 규칙 (현업에서 자주 밟는 지점)

  • 최신 브라우저는 기본적으로 SameSite=Lax에 가깝게 동작하며, 크로스사이트 POST/iframe 등에서 쿠키가 누락될 수 있습니다.
  • OAuth는 “외부(IdP) → 우리 도메인”으로 돌아오는 크로스사이트 내비게이션이 포함되므로, 구현 방식에 따라 SameSite=None; Secure가 필요합니다.

예: Express/Node 세션 쿠키 설정

app.set('trust proxy', 1); // Nginx 뒤면 필수(헤더 신뢰)

app.use(session({
  name: 'sid',
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: true,          // https에서만
    sameSite: 'none',      // OAuth/크로스사이트 흐름이면 필요할 수 있음
    path: '/',
  }
}));

여기서 trust proxy를 켜지 않으면, Express는 요청을 http로 판단해 secure: true 쿠키를 아예 세팅하지 않거나, 동작이 꼬여 “로그인 성공 → 다음 요청에 세션 없음” 루프가 납니다.

예: Spring Security에서 쿠키/세션이 꼬일 때

Spring Security 자체 세션(JSESSIONID)을 쓰든, 별도 쿠키를 쓰든 원리는 같습니다.

  • X-Forwarded-Proto 신뢰가 안 되면 Secure 쿠키가 기대대로 동작하지 않거나, 리다이렉트 URL이 http로 생성될 수 있습니다.
  • SameSite는 기본값이 환경마다 달라, 프론트/백 분리 + 크로스 도메인 조합에서 문제가 됩니다.

Spring Boot 3 기준으로는 server.forward-headers-strategy 설정과 함께, 필요 시 SameSite를 명시적으로 다루는 구성이 필요합니다(프레임워크/서버 조합에 따라 접근이 다름).

원인 4) Nginx의 redirect 재작성(proxy_redirect)로 Location이 변형됨

백엔드가 Location: http://app:8080/... 같은 값을 내려주면, Nginx가 이를 외부 도메인으로 바꿔줘야 합니다. 반대로, 이미 올바른 Location인데 Nginx가 잘못 재작성하면 루프가 생깁니다.

Nginx 설정 포인트

location / {
  proxy_pass http://app_upstream;

  # 기본적으로 on인 경우가 많지만, 환경에 따라 명시
  proxy_redirect off;
}
  • 백엔드가 항상 상대경로(/app)로만 리다이렉트하도록 만들면 이 계열 문제는 크게 줄어듭니다.
  • 어쩔 수 없이 절대 URL을 쓰면, proxy_redirect 규칙을 정확히 잡아야 합니다.

원인 5) state/nonce 검증 실패 → 앱이 재인증으로 회귀

OAuth/OIDC는 CSRF 방지를 위해 state(OIDC는 nonce)를 검증합니다. 이 값은 보통 “세션”이나 “임시 쿠키”에 저장됩니다.

  • 콜백에서 state가 맞지 않으면 → 인증 실패
  • 많은 앱은 실패 시 /login으로 302
  • 결과적으로 “콜백 → 실패 → 로그인” 루프

이 경우는 서버 로그에 state mismatch/invalid state가 찍히는 경우가 많습니다. 원인은 결국 위에서 말한 쿠키/세션 저장 실패(특히 SameSite/secure)로 귀결되는 일이 흔합니다.

10분 안에 끝내는 진단 순서(실전)

아래 순서대로 보면, 대부분 10분 내에 원인 후보가 1~2개로 줄어듭니다.

  1. DevTools Network에서 루프를 발생시키는 302 체인을 펼친다.
  2. 각 302 응답의 Locationhttp로 떨어지는지, www가 붙는지, 포트가 붙는지 확인한다.
  3. 콜백 응답에서 Set-Cookie가 내려오는지, 내려오는데도 다음 요청에 Cookie가 실리지 않는지 확인한다.
  4. Nginx access log에 $scheme $host $request_uri $upstream_http_location를 임시로 찍어 “어디서 http가 섞이는지”를 찾는다.

예: Nginx 로그 포맷(임시)

log_format oauth_debug '$remote_addr $scheme $host "$request" '
                      'status=$status location="$sent_http_location" '
                      'up_location="$upstream_http_location" '
                      'xfp="$http_x_forwarded_proto"';
access_log /var/log/nginx/access.log oauth_debug;

권장 기준 구성: Nginx + 앱 공통 베스트 프랙티스

1) Nginx는 원본 정보를 온전히 전달

server {
  listen 443 ssl;
  server_name example.com;

  location / {
    proxy_pass http://app_upstream;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Port 443;
  }
}

2) 앱은 프록시 헤더를 신뢰하고(특히 Node/Next), 외부 base URL을 일관되게 사용

  • Node/Express: trust proxy
  • Spring: server.forward-headers-strategy=framework
  • NextAuth/Next.js: NEXTAUTH_URL, trustHost(버전별) 등 “외부 URL”을 명시

3) 쿠키 정책을 OAuth 흐름에 맞춘다

  • 크로스사이트 리다이렉트에서 쿠키가 필요하면 SameSite=None; Secure
  • 도메인/서브도메인 전략을 명확히(예: auth.example.comapp.example.com 분리 시 특히)

마무리: 루프의 본질은 ‘외부에서 보이는 URL’과 ‘서버가 믿는 URL’의 불일치

Nginx 뒤 OAuth 콜백 302 무한리다이렉트는 복잡해 보이지만, 대부분 아래 둘 중 하나로 정리됩니다.

  • 서버가 요청을 http/내부 호스트로 오인해서 redirect_uri/Location을 잘못 만든다 → X-Forwarded-* + 프레임워크 신뢰 설정
  • 쿠키가 저장/전달되지 않아 state/세션이 유지되지 않는다 → Secure/SameSite/Domain/Path + 프록시 신뢰

위의 체크리스트와 설정을 적용해도 해결이 안 된다면, “어느 레이어에서 최초로 http가 섞이는지(예: ALB/Cloudflare/Nginx)”를 먼저 확정하고, 그 레이어에서 X-Forwarded-Proto/Forwarded 표준 헤더가 어떻게 변형되는지부터 역추적하는 방식이 가장 빠릅니다.