Published on

Cloudflare 뒤 OAuth 콜백 302 무한루프 해결법

Authors

서드파티 OAuth(구글/깃허브/애저AD 등)를 붙인 서비스가 Cloudflare 뒤로 들어가면, 특정 조건에서 콜백 URL이 계속 302로 튕기며 로그인 화면으로 되돌아가는 무한루프가 발생합니다. 로컬/직접 접속에서는 정상인데, Cloudflare 프록시(오렌지 구름)만 켜면 터지는 패턴이 흔합니다.

이 글은 “대체 왜 302가 반복되는가?”를 프로토콜(HTTP/HTTPS) 인지, 호스트/포트 인지, 쿠키 속성, 세션 저장, 리다이렉트 URI 관점에서 분해해서, 재현→진단→수정까지 한 번에 끝내는 체크리스트로 정리합니다.

증상: 콜백은 오는데 세션이 안 붙는다

대표적인 증상은 다음과 같습니다.

  • /oauth/callback 혹은 /auth/callback로 요청이 들어오지만 응답이 302
  • 302 Location이 다시 /login 혹은 OAuth authorize endpoint로 이동
  • 브라우저 네트워크 탭에서 302가 반복
  • 서버 로그에는 “state mismatch”, “invalid state”, “CSRF detected”, “session not found” 같은 메시지
  • Cloudflare를 우회(직접 오리진)하면 정상

이 상황의 본질은 보통 하나입니다.

> OAuth 플로우에서 저장해둔 state/nonce/세션 쿠키가 콜백 요청에 실려오지 않거나, 앱이 ‘원래 URL’을 잘못 계산해서 다시 authorize로 보내는 것

원인 1) 오리진은 HTTP로 보는데 외부는 HTTPS (X-Forwarded-Proto 미반영)

Cloudflare는 사용자와는 HTTPS로 통신해도, 오리진으로는 설정에 따라 HTTP로 붙을 수 있습니다(또는 TLS 종료 지점이 Cloudflare). 이때 애플리케이션이 다음을 잘못 판단합니다.

  • “현재 요청은 http다” → 리다이렉트 URI를 http로 생성
  • OAuth provider에는 https 콜백만 등록되어 있음 → 불일치
  • 혹은 프레임워크가 Secure 쿠키를 http로 판단해 설정하지 않음 → 세션 쿠키 미설정

진단

오리진 앱에서 아래 헤더를 로그로 찍어보세요.

  • X-Forwarded-Proto
  • X-Forwarded-Host
  • CF-Visitor (Cloudflare가 JSON 형태로 scheme 정보를 주기도 함)

Cloudflare를 통과한 요청에서 X-Forwarded-Proto: https가 오지 않거나, 앱이 이를 신뢰하지 않으면 루프가 납니다.

해결 (공통)

  1. Cloudflare SSL/TLS 모드는 가능하면 Full (strict)
  2. 오리진(리버스 프록시/앱 서버)에서 Forwarded 헤더를 신뢰하도록 설정
  3. 앱이 생성하는 외부 URL 기준(스킴/호스트)을 “프록시 기준”으로 맞추기

아래는 프레임워크별 대표 설정입니다.

해결 1-A) Node.js(Express)에서 trust proxy 설정

Express는 프록시 뒤에서 req.protocol, req.secure를 제대로 계산하려면 trust proxy가 필요합니다.

import express from "express";

const app = express();

// Cloudflare/로드밸런서 뒤라면 필수
app.set("trust proxy", true);

app.get("/debug", (req, res) => {
  res.json({
    protocol: req.protocol,
    secure: req.secure,
    host: req.get("host"),
    xfp: req.get("x-forwarded-proto"),
    xfh: req.get("x-forwarded-host"),
  });
});

trust proxy가 없으면 req.protocol이 http로 고정되어 콜백 URL/리다이렉트 URL 생성이 꼬일 수 있습니다.

해결 1-B) Spring Boot에서 Forwarded 헤더 처리

Spring Boot 2.6+에서는 프록시 헤더 처리 전략을 명시하는 것이 안전합니다.

server:
  forward-headers-strategy: framework

또는 톰캣/프록시 구성에 따라 native가 필요한 경우도 있습니다. 핵심은 외부에서 https로 들어온 요청을 앱이 https로 인지하게 만드는 것입니다.

해결 1-C) Django에서 SECURE_PROXY_SSL_HEADER

# settings.py
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
CSRF_TRUSTED_ORIGINS = ["https://example.com"]

원인 2) 쿠키 SameSite/Secure 설정 때문에 state 세션이 유실

OAuth는 대개 다음 중 하나에 의존합니다.

  • 서버 세션 쿠키(예: sessionid)
  • 임시 쿠키에 저장한 state/nonce
  • PKCE verifier를 세션에 저장

그런데 Cloudflare 뒤에서 도메인/스킴이 바뀌거나, OAuth provider → 내 서비스로 돌아오는 과정이 cross-site로 인식되면 쿠키가 빠질 수 있습니다.

특히 2020년 이후 브라우저는 기본 SameSite 정책이 강화되어,

  • SameSite=Lax는 “일부” 리다이렉트/POST 콜백에서 쿠키가 제외될 수 있고
  • SameSite=None을 쓰려면 반드시 Secure가 필요합니다.

흔한 실패 패턴

  • 콜백이 POST로 들어오는 provider(또는 프레임워크 설정)인데 쿠키가 안 옴
  • SameSite=None인데 Secure가 빠져서 브라우저가 쿠키를 저장 자체를 거부
  • http로 인지되어 Secure 쿠키가 설정되지 않음(원인 1과 결합)

해결: OAuth 관련 쿠키는 SameSite=None; Secure

Express + express-session 예시

import session from "express-session";

app.set("trust proxy", true);

app.use(
  session({
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      secure: true,      // HTTPS 필수
      sameSite: "none", // OAuth 리다이렉트 호환
    },
  })
);

주의: secure: true는 오리진이 https로 인지되어야 정상 동작합니다(그래서 trust proxy가 중요).

Spring Security(개념)

Spring Security에서 세션/쿠키 정책을 건드리는 경우가 많습니다. 무한루프라면 먼저 세션이 유지되는지부터 확인하세요. 콜백 요청에 JSESSIONID가 붙는지, 응답 Set-Cookie가 브라우저에서 차단되지 않는지 확인이 핵심입니다.

추가로 JWT 기반 리소스 서버에서 JWKS 설정이 꼬이면 로그인 후 토큰 검증에서 다시 튕길 수 있는데, 그 경우는 302 루프와 증상이 섞여 보일 수 있습니다. 토큰 검증 문제까지 의심된다면 JWT 검증에서 jwks_uri 404·kid 불일치 해결도 함께 점검하세요.

원인 3) Redirect URI/Host 불일치 (www 유무, 포트, trailing slash)

Cloudflare를 붙이면서 다음 변경이 자주 일어납니다.

  • example.comwww.example.com 강제 리다이렉트
  • 오리진은 :8080인데 외부는 443
  • /oauth/callback vs /oauth/callback/ 슬래시 차이

OAuth provider는 등록된 redirect URI와 완전 일치를 요구하는 경우가 많고, 프레임워크는 X-Forwarded-Host를 신뢰하지 않으면 내부 호스트로 redirect URI를 만들어버립니다.

진단

  1. 브라우저 네트워크에서 authorize 요청의 redirect_uri 파라미터를 확인
  2. provider 콘솔에 등록된 값과 바이트 단위로 일치하는지 확인
  3. Cloudflare Rules(리다이렉트 규칙)에서 www 강제/HTTP→HTTPS 강제가 콜백 경로에 적용되는지 확인

해결

  • 애플리케이션의 “외부 베이스 URL”을 명시적으로 고정(예: PUBLIC_URL=https://example.com)
  • Cloudflare Redirect Rules에서 콜백 경로는 예외 처리

예: Nginx 뒤 앱에서 외부 URL을 강제할 때(개념)

proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

원인 4) Cloudflare 캐시/자동 최적화가 콜백 엔드포인트를 건드림

OAuth 콜백은 절대 캐시되면 안 됩니다. 하지만 Cloudflare 설정/규칙이 잘못되면 HTML/302 응답이 캐시되어 이상한 루프가 생길 수 있습니다.

체크 포인트

  • Cache Rule에 “Cache Everything” 같은 규칙이 있는지
  • 콜백 경로(/oauth/*, /auth/*)가 캐시 예외인지
  • Transform Rules로 Location 헤더나 쿠키를 변경하지 않는지

권장 설정

  • /oauth/*, /auth/*, /login* 경로는 Cache Disabled
  • 응답에 Cache-Control: no-store를 명시(가능하면)

Express에서 콜백에 no-store 넣기 예시:

app.get("/oauth/callback", (req, res, next) => {
  res.set("Cache-Control", "no-store");
  next();
});

원인 5) Web/App 방화벽(WAF) 또는 Bot Fight가 state 파라미터를 차단

Cloudflare WAF 규칙이나 Bot 관리가 쿼리스트링을 공격으로 오탐할 수 있습니다.

  • state 값이 길고 랜덤 → 의심
  • code 파라미터 포함 → 룰에 걸림

진단

  • Cloudflare Security Events에서 해당 요청이 Challenge/Block 되었는지 확인
  • 브라우저에서 “잠깐 캡차 후 다시 로그인” 같은 이상 행동이 있는지 확인

해결

  • 콜백 경로에 대해 WAF 예외(또는 Managed Challenge 완화)
  • Bot Fight Mode/JS Challenge를 콜백 경로에서는 끄기

10분 진단 체크리스트 (가장 빨리 잡는 순서)

아래 순서대로 보면 대부분 10분 내 원인이 좁혀집니다.

  1. 브라우저 DevTools → Network
    • 302 Location 체인이 어디서 시작/반복되는지 확인
    • 콜백 요청에 쿠키가 포함되는지 확인
  2. 서버 로그에 헤더 덤프
    • X-Forwarded-Proto, X-Forwarded-Host, Host
  3. 세션 쿠키 Set-Cookie 확인
    • SameSite=None; Secure가 맞는지
    • 브라우저 콘솔/애플리케이션 탭에서 쿠키가 “차단됨” 표시가 있는지
  4. OAuth provider redirect URI 일치 확인
    • www/슬래시/포트까지
  5. Cloudflare 캐시/WAF 이벤트 확인

인프라 쪽에서 5xx(502/503)가 섞여 보이면, 루프와 별개로 오리진 장애가 동반된 것일 수 있습니다. EKS/쿠버네티스 환경이라면 EKS에서 503 Service Unavailable 원인 10분 진단처럼 “요청은 도착하는데 백엔드가 불안정해서 인증 플로우가 깨지는” 케이스도 같이 배제하는 게 좋습니다.

실전 예시: “Cloudflare 켜면 무한루프”의 가장 흔한 조합

현장에서 가장 자주 본 조합은 이렇습니다.

  • Cloudflare 뒤 오리진은 HTTP
  • Express/Spring/Django가 X-Forwarded-Proto를 신뢰하지 않음
  • 세션 쿠키가 Secure로 설정되지 않거나, SameSite=Lax라 콜백에서 빠짐
  • 결과적으로 state 검증 실패 → 다시 /login으로 302 → 무한루프

이 경우 정답은 보통 2줄입니다.

  • 앱: 프록시 신뢰 설정(Express trust proxy, Spring forward-headers-strategy, Django SECURE_PROXY_SSL_HEADER)
  • 쿠키: SameSite=None; Secure

마무리: “302 루프”는 인증 문제가 아니라 ‘경계면’ 문제다

OAuth 콜백 302 무한루프는 대개 코드 로직 자체가 아니라 Cloudflare(엣지) ↔ 오리진(앱) 경계에서 스킴/호스트/쿠키 정책이 어긋나서 생깁니다.

  • 앱이 외부 스킴(https)을 정확히 인지하는가?
  • 쿠키가 cross-site 리다이렉트에서 살아남는가?
  • redirect URI가 정확히 일치하는가?
  • Cloudflare가 콜백을 캐시/차단하지 않는가?

위 4가지만 정리하면, 대부분의 302 무한루프는 재발 없이 정리됩니다.