- Published on
Nginx 뒤 OAuth 콜백 400 - HTTPS·프록시 설정
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버는 잘 살아있는데 OAuth 로그인만 하면 콜백에서 400 Bad Request가 떨어지는 케이스가 있습니다. 특히 Nginx 리버스 프록시 뒤에 애플리케이션을 두고 TLS(HTTPS)는 Nginx에서 종료하는 구성에서 자주 발생합니다. 겉으로는 브라우저 주소창이 HTTPS인데, 백엔드가 내부에서 HTTP로 인식하면서 redirect_uri가 달라지고, IdP(구글/깃허브/카카오 등) 또는 애플리케이션의 OAuth 검증 로직이 이를 거부하는 흐름입니다.
이 글은 “왜 400이 나는지”를 스킴·호스트·포트·경로 관점에서 분해하고, Nginx 설정과 애플리케이션 프레임워크별(특히 Spring) 프록시 인지 설정까지 한 번에 정리합니다.
관련해서 redirect_uri/state 불일치로 인증이 꼬이는 패턴은 아래 글도 함께 보면 진단 속도가 빨라집니다.
증상 패턴: “콜백만 400”이 의미하는 것
다음 중 하나라도 보이면 프록시/HTTPS 인식 문제일 확률이 높습니다.
- IdP에서 로그인까지는 성공하지만, 내 서비스의 콜백 URL로 돌아오는 순간
400(또는401,403)이 발생 - IdP 콘솔에 등록한
redirect_uri는 HTTPS인데, 서버 로그에는 HTTP로 들어온 것으로 기록 Location리다이렉트가http://로 생성되거나, 포트가:8080같은 내부 포트로 붙어서 나감- Spring Security 사용 시
Invalid redirect_uri또는authorization_request_not_found류의 로그
OAuth는 “인가 코드(code)를 어디로 돌려보낼지”를 redirect_uri로 강하게 묶습니다. 프록시 뒤에서 앱이 외부 URL을 잘못 추론하면, 다음 중 한 지점에서 깨집니다.
- IdP가
redirect_uri불일치로 거부 - 애플리케이션이 “요청이 안전하지 않다”고 판단하고 차단
state/세션 쿠키가Secure/도메인/경로 문제로 누락되어 콜백 검증 실패
원인 1: X-Forwarded-* 헤더 누락 또는 불일치
TLS를 Nginx에서 종료하면 백엔드는 일반적으로 HTTP로 요청을 받습니다. 이때 백엔드가 “원래 클라이언트는 HTTPS로 접속했다”는 정보를 알 수 있게 해주는 표준이 X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port 입니다.
이 헤더가 없거나 값이 틀리면 백엔드는 외부 URL을 다음처럼 잘못 조합합니다.
- 외부:
https://app.example.com - 내부 인식:
http://app.example.com:8080
그 결과 OAuth 라이브러리가 생성하는 redirect_uri가 IdP 등록값과 달라져 실패합니다.
원인 2: 애플리케이션이 프록시 헤더를 신뢰하지 않음
헤더를 잘 넣어도, 프레임워크가 이를 “신뢰할 프록시에서 온 정보”로 인정하지 않으면 무시합니다.
- Spring Boot / Spring Security:
ForwardedHeaderFilter또는server.forward-headers-strategy설정 필요 - Express, Django, Rails 등:
trust proxy또는 proxy header 처리 설정 필요
이 부분이 설정되지 않으면, 로그에는 계속 HTTP로 찍히고, 리다이렉트 URL도 HTTP로 생성됩니다.
원인 3: 쿠키 Secure/SameSite/도메인 문제로 state 검증 실패
콜백 400이 애플리케이션에서 나는 경우(특히 Spring Security) state 검증이 실패해서 400/401로 떨어질 수 있습니다. 프록시 환경에서 흔한 원인은 다음입니다.
- 세션 쿠키가
Secure인데, 서버가 HTTP로 인식해 쿠키를 안 붙이거나 SameSite정책 때문에 IdP에서 돌아오는 크로스 사이트 리다이렉트에 쿠키가 누락되거나- 도메인이
app.example.com인데 쿠키 도메인이example.com또는 반대로 설정되어 불일치
이 경우는 redirect_uri만 맞춰도 해결이 안 되고, 쿠키/세션 레이어까지 같이 봐야 합니다.
1단계: Nginx 리버스 프록시 설정(정석)
아래 설정은 “외부는 HTTPS, 내부는 HTTP 업스트림”일 때 기본 골격입니다. 핵심은 X-Forwarded-*를 명시적으로 세팅하고, Host를 보존하는 것입니다.
server {
listen 443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
# (선택) HSTS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://backend_upstream;
# 원본 Host 유지
proxy_set_header Host $host;
# 클라이언트 IP/체인
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;
# WebSocket 등 업그레이드가 필요하면
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 60s;
}
}
upstream backend_upstream {
server 127.0.0.1:8080;
keepalive 32;
}
추가로, Nginx가 이미 X-Forwarded-Proto를 세팅하는데 앞단에 또 다른 프록시(예: Cloudflare, ALB)가 있으면 값이 꼬일 수 있습니다. 이때는 “가장 바깥 스킴”을 일관되게 전달하도록 체인을 정리해야 합니다.
2단계: Spring Boot / Spring Security에서 프록시 헤더 신뢰하기
Nginx가 헤더를 잘 넣어도 Spring이 무시하면 의미가 없습니다. Spring Boot 2.6+ 기준으로는 아래 설정이 가장 간단합니다.
application.yml
server:
forward-headers-strategy: native
환경에 따라 framework가 더 맞는 경우도 있습니다. 핵심은 “Forwarded 또는 X-Forwarded-*를 기반으로 request URL을 재구성”하게 만드는 것입니다.
또는 명시적으로 필터를 등록할 수도 있습니다.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.filter.ForwardedHeaderFilter;
@Configuration
public class ProxyConfig {
@Bean
ForwardedHeaderFilter forwardedHeaderFilter() {
return new ForwardedHeaderFilter();
}
}
이 설정 후에는 다음이 개선되는지 확인합니다.
request.getScheme()가https로 인식되는가- Spring Security가 생성하는 OAuth2 인증 요청의
redirect_uri가 HTTPS로 나가는가
3단계: redirect_uri를 “한 곳에서” 고정하기
프록시 환경에서는 동적으로 조합한 redirect_uri가 엣지 케이스를 만들기 쉽습니다. 가능하면 애플리케이션 설정으로 외부 base URL을 고정하는 전략이 안전합니다.
Spring Security OAuth2 Client를 예로 들면, 보통 리다이렉트 URI 템플릿은 다음 형태를 씁니다.
"{baseUrl}/login/oauth2/code/{registrationId}"
여기서 {baseUrl} 계산이 프록시 헤더에 의존합니다. 프록시 헤더 신뢰가 불완전하면 곧바로 불일치로 이어집니다.
운영에서는 다음을 함께 권장합니다.
- IdP 콘솔에 등록한 콜백 URL을 정확히 1개 또는 소수로 고정
- 앱에서도 외부 도메인/스킴이 바뀌지 않게 정규화
- 멀티 도메인(예:
www유무, 지역 도메인)이라면 리다이렉트 정책을 명확히 분리
4단계: 쿠키 이슈로 인한 400을 분리 진단하기
콜백 400이 “IdP가 반환한 요청” 자체는 정상인데, 애플리케이션이 state 검증에서 실패해서 400을 내는 경우가 있습니다. 이때는 네트워크 탭에서 콜백 요청에 쿠키가 실려오는지부터 확인합니다.
체크리스트:
- 콜백 요청에 세션 쿠키가 포함되는가
- 세션 쿠키에
Secure가 붙어 있다면, 브라우저가 HTTPS에서만 전송하는데 현재 브라우저 주소창은 HTTPS가 맞는가 SameSite=Lax또는SameSite=None; Secure조합이 필요한 플로우인지- 도메인 스코프가 실제 서비스 도메인과 일치하는지
특히 최근 브라우저 정책에서 SameSite=None을 쓰려면 Secure가 필수입니다. 그런데 백엔드가 HTTP로 인식해 Secure 쿠키를 잘못 다루면 인증 플로우가 불안정해집니다.
5단계: 로깅으로 “외부 URL 재구성”이 맞는지 확인
원인을 빨리 좁히려면 콜백 요청이 들어왔을 때 다음 값을 로그로 남겨 보세요.
scheme,serverName,serverPortHostX-Forwarded-Proto,X-Forwarded-Host,X-Forwarded-Port- 최종적으로 생성된
redirect_uri
Spring MVC 기준으로는 인터셉터에서 대략 아래처럼 확인할 수 있습니다.
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.servlet.HandlerInterceptor;
public class ForwardedHeaderDebugInterceptor implements HandlerInterceptor {
private static final Logger log = LoggerFactory.getLogger(ForwardedHeaderDebugInterceptor.class);
@Override
public boolean preHandle(HttpServletRequest req, jakarta.servlet.http.HttpServletResponse res, Object handler) {
log.info("scheme={} hostHeader={} serverName={} serverPort={} xfProto={} xfHost={} xfPort={}",
req.getScheme(),
req.getHeader("Host"),
req.getServerName(),
req.getServerPort(),
req.getHeader("X-Forwarded-Proto"),
req.getHeader("X-Forwarded-Host"),
req.getHeader("X-Forwarded-Port"));
return true;
}
}
이 로그 한 줄로 “Nginx가 헤더를 잘 넣는지”와 “Spring이 그걸 반영하는지”를 동시에 판별할 수 있습니다.
자주 하는 실수 모음
proxy_set_header Host를 $proxy_host로 둠
업스트림 호스트(예: 127.0.0.1:8080)가 Host로 전달되면, 백엔드는 외부 도메인을 잃습니다. OAuth는 도메인 정합성이 매우 중요하므로 Host $host를 권장합니다.
X-Forwarded-Proto가 항상 http로 들어옴
앞단에 L7 로드밸런서가 있고, Nginx는 내부 통신만 받는 구조에서 종종 발생합니다. 이 경우 Nginx가 보는 $scheme 자체가 http이므로, 로드밸런서에서 X-Forwarded-Proto=https를 내려주고 Nginx가 그 값을 신뢰하도록 체인을 정리해야 합니다.
HTTP 80에서 443으로 리다이렉트가 깨짐
80 리스너에서 return 301 https://...를 할 때, 호스트/경로를 잘못 조합하면 OAuth 시작 URL이 달라져 state가 어긋날 수 있습니다. HTTP 서버 블록은 단순 리다이렉트로만 두고, OAuth 엔드포인트는 항상 HTTPS에서 시작하게 만드는 편이 안전합니다.
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
운영 환경 체크리스트(최종)
- Nginx가
X-Forwarded-Proto/Host/Port를 일관되게 전달한다 - 애플리케이션이 프록시 헤더를 신뢰하고 외부 URL을 HTTPS로 재구성한다
- IdP에 등록된
redirect_uri와 애플리케이션이 생성하는redirect_uri가 완전히 동일하다(스킴, 호스트, 포트, 경로, 슬래시 포함) - 콜백 요청에 세션 쿠키가 포함되고,
SameSite/Secure정책이 플로우에 맞다 - 다중 프록시 체인(ALB, CDN, Nginx)이면 “가장 바깥 스킴”이 끝까지 유지된다
마무리
Nginx 뒤에서 OAuth 콜백 400이 나는 문제는 대부분 “외부에서 보이는 URL”과 “백엔드가 인식하는 URL”의 불일치에서 시작합니다. Nginx에서 X-Forwarded-*를 정확히 전달하고, 애플리케이션이 이를 신뢰하도록 설정한 뒤, 최종 redirect_uri가 IdP 등록값과 1바이트도 다르지 않게 맞추면 재현성 있게 해결됩니다.
그래도 해결이 안 되면, 다음 순서로 원인을 분리해 보세요.
- IdP 단계에서 거부인지(에러가 IdP 화면에서 나는지)
- 내 애플리케이션 콜백에서 거부인지(서버 로그에 남는지)
- 쿠키/세션 문제인지(콜백 요청에 쿠키가 실리는지)
대부분은 위 3단계 분리만으로 “왜 400인지”가 명확해지고, 설정 수정도 최소화할 수 있습니다.