- Published on
Chrome SameSite 쿠키로 OAuth 콜백이 실패할 때
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 로그인(OAuth/OIDC)을 붙였는데, Chrome에서만 간헐적으로 콜백이 실패하는 케이스가 있습니다. 대표 증상은 state mismatch, invalid_state, CSRF detected, 혹은 “로그인 성공 후 다시 로그인 화면으로 튕김” 같은 형태입니다. 서버 로그를 보면 콜백 요청에서 세션/임시 쿠키가 안 들어와 state(또는 nonce) 검증이 실패하는 경우가 많습니다.
이 글은 그 원인이 Chrome SameSite 쿠키 정책일 때 어떻게 진단하고, 어떤 설정을 바꿔야 하며, 보안적으로 어떤 선택이 안전한지까지 한 번에 정리합니다.
왜 OAuth 콜백에서 쿠키가 중요할까
OAuth Authorization Code Flow(또는 OIDC)에서 브라우저는 대략 다음 흐름을 탑니다.
- 내 서비스
GET /login→ 서버가state(CSRF 방지) 생성 - 서버가
state를 세션/쿠키 혹은 서버 저장소에 매핑 - 브라우저가 IdP(구글/깃허브 등)로 이동
- IdP가 내 서비스 콜백으로 리다이렉트:
GET /oauth/callback?code=...&state=... - 내 서비스는 콜백 요청에서 쿠키/세션을 읽어 2번에서 저장한
state와 비교
여기서 5번에 쿠키가 누락되면 서버는 “이 요청이 같은 브라우저에서 시작된 로그인인지” 확인할 수 없어 실패합니다.
Chrome SameSite 정책이 콜백을 깨는 전형적인 이유
1) SameSite 기본값 변화: Lax-by-default
Chrome은 오래전부터 SameSite 미지정 쿠키를 Lax로 취급합니다. 즉, 다음과 같은 쿠키는 사실상 SameSite=Lax입니다.
Set-Cookie: session=...; Path=/; HttpOnly
Lax는 “일반적인 크로스사이트 요청에는 쿠키를 안 보냄”이 기본입니다. 다만 상위 레벨 네비게이션(top-level navigation) GET에는 예외적으로 쿠키가 포함될 수 있어, OAuth 콜백이 GET 리다이렉트라면 “되기도 하고 안 되기도” 하는 애매한 상태가 됩니다.
2) 콜백이 POST(또는 iframe/팝업)로 들어오는 경우
IdP/라이브러리 설정에 따라 콜백이 POST(예: response_mode=form_post)로 들어오거나, 팝업/iframe 기반으로 처리되는 경우가 있습니다. 이때 SameSite=Lax는 쿠키를 보내지 않아서 Chrome에서만 실패가 튀어나옵니다.
3) SameSite=None을 썼지만 Secure가 빠진 경우
크로스사이트에서 쿠키를 항상 보내려면 SameSite=None이 필요합니다. 하지만 Chrome은 SameSite=None 쿠키에 **반드시 Secure**가 붙어야 한다는 규칙을 강제합니다.
- 잘못된 예:
SameSite=None인데Secure없음 → Chrome이 쿠키를 무시
4) 개발 환경(HTTP)에서의 함정
로컬 개발에서 HTTPS가 아니라면 Secure를 붙일 수 없고, SameSite=None; Secure 조합을 못 쓰니 OAuth 콜백이 꼬이기 쉽습니다. 이때는 로컬에서도 HTTPS 터널(ngrok 등) 또는 로컬 TLS를 쓰는 게 현실적인 해법입니다.
증상 패턴으로 빠르게 판별하기
다음 중 2개 이상이면 SameSite 이슈 가능성이 매우 높습니다.
- Safari/Firefox에서는 되는데 Chrome에서만 실패
- 실패 로그가
state mismatch,invalid state,CSRF계열 - 콜백 요청에서 서버가 세션을 새로 만들거나(세션 ID 변경) “세션 없음”으로 처리
- DevTools → Network → 콜백 요청에 Cookie 헤더가 비어 있음
Chrome DevTools로 확실히 진단하는 방법
1) 콜백 요청에 쿠키가 실렸는지 확인
- DevTools → Network
oauth/callback요청 클릭- Request Headers에서
Cookie:유무 확인
쿠키가 없다면 SameSite/도메인/경로 문제입니다.
2) Application 탭에서 쿠키 속성 확인
DevTools → Application → Cookies에서 해당 쿠키의:
- SameSite 값(Lax/Strict/None)
- Secure 여부
- Domain/Path
- Expires/Max-Age
를 확인합니다.
3) “This Set-Cookie was blocked…” 경고 확인
Network에서 로그인 시작 응답(쿠키를 세팅하는 응답)을 보면 Chrome이 쿠키를 차단한 이유를 친절히 적어줍니다.
SameSite=None인데Secure가 없어서 차단- 도메인 불일치
- 제3자 쿠키 정책에 의해 차단(상황에 따라)
해결 전략 1: OAuth용 임시 쿠키를 SameSite=None; Secure로
가장 정석적인 해결은 OAuth state/nonce를 담는 쿠키만 크로스사이트 전송 가능하도록 만드는 것입니다.
예시(HTTP 응답 헤더):
Set-Cookie: oauth_state=abc123; Path=/oauth; HttpOnly; Secure; SameSite=None; Max-Age=300
핵심 포인트:
SameSite=None+Secure는 세트- 만료를 짧게(수 분) 잡아 공격면을 줄임
Path=/oauth처럼 범위를 좁혀 노출 면적을 줄임
Express(Node.js) 예시
import express from "express";
import cookieParser from "cookie-parser";
const app = express();
app.use(cookieParser());
app.get("/login", (req, res) => {
const state = crypto.randomUUID();
res.cookie("oauth_state", state, {
httpOnly: true,
secure: true, // HTTPS 필수
sameSite: "none", // 크로스사이트 전송 허용
path: "/oauth",
maxAge: 5 * 60 * 1000,
});
const redirect = new URL("https://idp.example.com/auth");
redirect.searchParams.set("state", state);
redirect.searchParams.set("redirect_uri", "https://app.example.com/oauth/callback");
res.redirect(redirect.toString());
});
app.get("/oauth/callback", (req, res) => {
const stateFromQuery = req.query.state;
const stateFromCookie = req.cookies.oauth_state;
if (!stateFromCookie || stateFromCookie !== stateFromQuery) {
return res.status(401).send("invalid_state");
}
res.clearCookie("oauth_state", { path: "/oauth" });
res.send("ok");
});
해결 전략 2: 세션 쿠키를 건드려야 하는 경우(주의)
기존 구현이 state를 세션에 저장하고, 세션 쿠키가 콜백에서 반드시 필요하다면 세션 쿠키 자체를 SameSite=None으로 바꾸고 싶어질 수 있습니다. 하지만 이건 사이트 전체 요청에 대해 크로스사이트 쿠키 전송을 허용하는 것이어서 공격면이 커질 수 있습니다.
가능하면:
- 세션 쿠키는
Lax유지 - OAuth 전용 임시 쿠키만
None으로 분리
를 권장합니다.
Spring Boot(Tomcat)에서 SameSite 설정 예시
스프링/서블릿 기반에서 쿠키 SameSite를 제어하는 방법은 버전/컨테이너에 따라 다릅니다. 최근엔 server.servlet.session.cookie.same-site 같은 설정을 제공합니다.
server:
servlet:
session:
cookie:
same-site: none
secure: true
단, 이 설정은 “세션 쿠키”에 적용되는 경우가 많습니다. OAuth 전용 쿠키를 분리해서 직접 Set-Cookie로 내리는 편이 더 안전합니다.
해결 전략 3: 로컬/개발 환경에서 HTTPS 강제하기
SameSite=None은 Secure가 필수이므로 개발 환경이 HTTP면 막힙니다. 해결책은 다음 중 하나입니다.
- 로컬에서도 HTTPS(자체 서명 인증서) 적용
- ngrok/Cloudflare Tunnel로 HTTPS 도메인 부여
- 개발 환경에서는 OAuth 콜백을 같은 사이트/도메인으로 맞추기(가능한 경우)
개발에서만 secure: false로 타협하면, Chrome이 SameSite=None 쿠키를 무시하므로 “개발에서만 OAuth가 안 됨” 상태가 됩니다.
자주 놓치는 추가 원인 체크리스트
SameSite로 보이지만 실제론 다른 문제인 경우도 있어, 아래를 함께 확인하세요.
1) 도메인/서브도메인 불일치
- 로그인 시작:
app.example.com - 콜백:
api.example.com/oauth/callback
인데 쿠키 Domain이 app.example.com으로만 잡혀 있으면 콜백 도메인에 쿠키가 안 갑니다.
필요하면:
Domain=.example.com(서브도메인 공유)
를 고려하지만, 공유 범위가 넓어지는 만큼 보안/격리 관점에서 신중해야 합니다.
2) 프록시/Ingress에서 Secure가 떨어지는 문제
TLS 종료를 ALB/Ingress에서 하고, 앱 서버는 HTTP로 받는 구성에서 프레임워크가 “내가 HTTP로 통신 중이네?”라고 판단해 Secure 쿠키를 안 붙이는 경우가 있습니다.
X-Forwarded-Proto: https신뢰 설정- 프레임워크의 proxy trust 설정
이 필요합니다.
EKS/Ingress 환경에서 403/리다이렉트가 엮여 증상이 복잡해지면, 인프라 레벨에서 원인 분리하는 접근이 도움이 됩니다. 관련해서는 EKS ALB Ingress 403, WAF 아닌 원인 7가지 같은 체크리스트를 함께 참고하면 진단 속도가 빨라집니다.
3) 콜백 URL이 GET이 아니라 POST인지 확인
OIDC에서 response_mode=form_post를 쓰면 콜백이 POST로 들어옵니다. 이 경우 SameSite=Lax 세션 쿠키가 제외될 수 있어, Chrome에서 state 검증이 더 자주 깨집니다. 가능하다면 콜백을 GET 리다이렉트로 유지하거나, 위에서 말한 SameSite=None; Secure 임시 쿠키 전략을 쓰세요.
보안 관점에서의 권장 구성(현실적인 베스트 프랙티스)
정리하면 다음 조합이 가장 깔끔합니다.
- 세션 쿠키:
HttpOnly; Secure; SameSite=Lax(기본) - OAuth state/nonce 전용 임시 쿠키:
HttpOnly; Secure; SameSite=None; Max-Age=300; Path=/oauth - 콜백 처리 후 임시 쿠키 즉시 삭제
- 프록시 환경에서는
X-Forwarded-Proto신뢰 설정으로Secure누락 방지
이렇게 하면 “OAuth 콜백”이라는 특수한 크로스사이트 단계만 최소 범위로 열어두고, 나머지는 Lax로 방어선을 유지할 수 있습니다.
운영에서 디버깅 로그를 이렇게 남기면 빨라진다
SameSite 문제는 사용자 브라우저/정책/리다이렉트 조건에 따라 재현이 들쑥날쑥합니다. 운영에서 빠르게 잡으려면 콜백 엔드포인트에 다음을 구조적으로 로깅하세요.
- 요청의
User-Agent(Chrome 버전) Cookie헤더 존재 여부(값 전체는 민감하니 “존재/키 목록” 정도)state검증 실패 사유(쿠키 없음 vs 불일치)X-Forwarded-Proto,Host(프록시 구성 확인)
에러가 복잡하게 얽힐 때는 “입력(요청 헤더/쿠키) → 판단(검증) → 출력(리다이렉트/에러)” 형태로 로그를 남기는 게 효과적입니다. 이런 디버깅 사고방식은 OpenAI Responses API 400 에러 - schema·tool 호출 디버깅처럼 원인 분해가 중요한 케이스에도 그대로 적용됩니다.
마무리: 체크 순서만 지켜도 대부분 해결된다
Chrome SameSite로 OAuth 콜백이 실패할 때는 순서가 중요합니다.
- 콜백 요청에 쿠키가 실렸는지부터 확인
- 쿠키가 없다면:
SameSite/Secure/도메인/경로/프록시 헤더를 점검 - 가장 안전한 해결은: OAuth 전용 임시 쿠키만
SameSite=None; Secure로 분리 - 개발 환경은 HTTPS 없으면 재현이 계속 꼬이므로, 로컬 HTTPS/터널을 도입
이 흐름대로 보면 “Chrome에서만 state mismatch” 같은 골치 아픈 이슈도 대부분 짧은 시간 안에 정리됩니다.