SameSite 쿠키가 로그인 흐름에 미치는 영향
SameSite 쿠키가 로그인 흐름에 미치는 영향
Strict는 대부분의 크로스 사이트 요청에서 쿠키를 보내지 않는다. Lax는 최상위 이동의 안전한 요청 등 일부를 허용한다. None은 Secure와 함께 사용해야 하며 크로스 사이트 전송을 허용한다.
목차
- #왜 이 문제가 생기는가
- #origin과 site는 같은 개념이 아니다
- #Strict, Lax, None 비교
- #OAuth 리다이렉트에서 세션이 사라지는 이유
- #목적별 쿠키를 분리하기
- #Secure, HttpOnly, Domain, Path의 역할
- #CORS와 SameSite를 혼동하지 않기
- #SameSite만으로 CSRF 방어를 끝내면 안 된다
- #로컬 개발과 운영 환경의 차이
- #문제를 재현할 때 확인할 것
- #결론
- #관련 노트
왜 이 문제가 생기는가
소셜 로그인 페이지에서는 인증이 성공했는데 우리 서비스의 콜백으로 돌아오자 “로그인 요청이 만료되었습니다”라는 오류가 발생할 수 있다. 서버 로그에는 콜백 요청이 정상적으로 들어왔지만, 로그인 시작 전에 저장한 세션 쿠키가 요청에 포함되지 않은 것이다.
이 문제를 CORS 설정으로 해결하려 하거나 모든 쿠키를 SameSite=None으로 바꾸면 원인과 맞지 않는 수정이 된다. SameSite는 브라우저가 요청 문맥을 보고 쿠키를 전송할지 결정하는 속성이다. 로그인 리다이렉트, 외부 결제 복귀, iframe 임베드처럼 사이트 경계를 넘는 흐름을 설계할 때 쿠키 목적별로 판단해야 한다.
도메인과 쿠키 값은 동작 설명을 위해 만든 가상의 값이다. 실제 서비스의 도메인이나 인증 코드를 사용하지 않았다.
origin과 site는 같은 개념이 아니다
웹 보안 문맥에서 origin은 대략 scheme + host + port의 조합이다.
https://app.example.test:443
└─ scheme └──── host ────┘ port
다음 두 주소는 host가 다르므로 서로 다른 origin이다.
https://app.example.test
https://api.example.test
하지만 SameSite가 판단하는 site는 origin보다 넓은 개념이다. 등록 가능한 도메인을 중심으로 보고, 현대적인 판단에서는 scheme도 고려한다. 따라서 서로 다른 subdomain은 cross-origin이면서 same-site일 수 있다. 반대로 http와 https처럼 scheme이 달라지면 같은 host처럼 보여도 SameSite 문맥이 달라질 수 있다.
| 비교 | same-origin | same-site 가능성 |
|---|---|---|
https://app.example.test ↔ 같은 주소 |
O | O |
https://app.example.test ↔ https://api.example.test |
X | O |
https://app.example.test ↔ https://accounts.other.test |
X | X |
http://app.example.test ↔ https://app.example.test |
X | X로 판단될 수 있음 |
cross-origin 요청이라고 반드시 cross-site 요청인 것은 아니다. 이 차이를 놓치면 CORS 문제와 SameSite 문제를 서로 바꿔서 디버깅하게 된다.
Strict, Lax, None 비교
SameSite 속성은 크로스 사이트 문맥에서 쿠키를 보낼 범위를 정한다.
| 값 | 크로스 사이트 전송 | 대표 용도 | 주의점 |
|---|---|---|---|
Strict |
가장 제한적 | 사이트 내부에서만 필요한 민감한 상태 | 외부 링크·로그인 복귀 흐름에서 쿠키가 빠질 수 있음 |
Lax |
일부 최상위 이동 허용 | 일반적인 로그인 세션 | iframe이나 크로스 사이트 하위 요청에는 맞지 않을 수 있음 |
None |
크로스 사이트 문맥 허용 | 임베드, 명시적인 cross-site 연동 | Secure가 필요하고 CSRF 노출 면을 검토해야 함 |
일반적인 웹 세션 쿠키는 다음처럼 시작해 볼 수 있다.
Set-Cookie: __Host-session=opaque-session-id; Path=/; Secure; HttpOnly; SameSite=Lax
속성을 생략했을 때의 세부 동작이나 호환성에 기대기보다, 애플리케이션이 원하는 정책을 명시하는 편이 이해하기 쉽다. 특히 오래된 브라우저와 웹뷰를 지원한다면 대상 환경에서 직접 검증해야 한다.
OAuth 리다이렉트에서 세션이 사라지는 이유
OAuth 로그인의 단순화된 흐름을 보자.
sequenceDiagram
participant U as Browser
participant A as app.example.test
participant I as accounts.other.test
U->>A: 로그인 시작
A-->>U: state 저장 + 세션 쿠키
A-->>U: 인증 서버로 redirect
U->>I: 사용자 인증
I-->>U: callback URL로 redirect
U->>A: GET /oauth/callback?code=...&state=...
Note over U,A: SameSite 정책에 따라 세션 쿠키 전송 여부 결정
A-->>U: state 검증 후 애플리케이션 세션 확정로그인 시작 시 서버는 state와 PKCE 검증 값 같은 일회성 상태를 세션에 보관할 수 있다. 인증 서버에서 돌아온 콜백 요청에 이 세션 쿠키가 없으면 서버는 전달받은 state를 원래 요청과 비교할 수 없다.
Strict 쿠키는 외부 사이트에서 돌아오는 최상위 이동에서도 제외될 수 있어 이런 흐름을 깨뜨릴 수 있다. 일반적인 최상위 GET 콜백에는 Lax가 맞을 수 있지만, 응답 방식이 폼 POST이거나 iframe 안에서 진행된다면 문맥이 달라진다. “OAuth는 무조건 Lax” 같은 규칙 대신 실제 콜백 방식과 브라우저 요청 문맥을 확인해야 한다.
목적별 쿠키를 분리하기
모든 상태를 하나의 세션 쿠키에 넣으면 가장 느슨한 요구 사항이 전체 정책을 결정한다. 외부 로그인 콜백에 필요한 짧은 상태와 사이트 내부의 장기 세션은 목적과 수명이 다르므로 분리할 수 있다.
# 사이트 내부에서 사용하는 일반 세션
Set-Cookie: __Host-session=session-id; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=7200
# 외부 인증 복귀를 검증하는 짧은 일회성 상태의 예
Set-Cookie: __Host-oauth-state=state-id; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=300
iframe 기반 연동처럼 정말 cross-site 쿠키가 필요하다면 해당 목적의 쿠키만 None으로 제한한다.
Set-Cookie: __Secure-embed-session=embed-id; Path=/embed; Secure; HttpOnly; SameSite=None; Max-Age=900
이렇게 분리하면 수명, 전송 범위, 삭제 시점을 쿠키 목적에 맞게 좁힐 수 있다. 단, 브라우저의 서드파티 쿠키 제한은 SameSite=None보다 별도의 정책이므로, None을 붙였다고 모든 임베드 환경에서 항상 전송된다고 가정하면 안 된다.
Secure, HttpOnly, Domain, Path의 역할
SameSite 외 속성은 각기 다른 경계를 제어한다.
| 속성 | 역할 | 오해하기 쉬운 점 |
|---|---|---|
Secure |
HTTPS 연결에서만 쿠키 전송 | 쿠키 내용 자체를 암호화하는 속성은 아님 |
HttpOnly |
JavaScript의 쿠키 읽기 제한 | 브라우저가 요청에 쿠키를 보내는 것은 막지 않음 |
Domain |
쿠키를 보낼 host 범위 확장 | 권한 경계가 넓어질 수 있음 |
Path |
요청 경로에 따른 전송 범위 | 보안상 접근 제어 수단은 아님 |
Max-Age/Expires |
쿠키 수명 지정 | 서버 세션의 실제 만료와 별개일 수 있음 |
가능하다면 Domain을 생략한 host-only 쿠키로 범위를 좁힌다. __Host- 접두사를 사용한 쿠키는 Secure, Path=/, Domain 생략 같은 제약을 통해 더 명확한 host 경계를 표현할 수 있다.
서버 세션도 별도로 만료시켜야 한다. 브라우저 쿠키만 삭제하고 서버의 세션 레코드를 계속 유효하게 두면 탈취된 식별자가 다른 곳에서 재사용될 가능성을 남긴다.
CORS와 SameSite를 혼동하지 않기
프론트엔드 JavaScript가 다른 origin의 API를 호출할 때는 여러 조건이 동시에 관여한다.
const response = await fetch("https://api.example.test/profile", {
method: "GET",
credentials: "include",
headers: {
Accept: "application/json",
},
});
credentials: "include"는 자격 증명을 포함할 의도를 나타내지만, SameSite 정책상 제외되는 쿠키를 강제로 전송하지는 않는다. 서버의 CORS 응답 역시 올바르게 설정되어야 JavaScript가 응답을 읽을 수 있다.
Access-Control-Allow-Origin: https://app.example.test
Access-Control-Allow-Credentials: true
자격 증명 요청에서 허용 origin을 무심코 *로 두는 방식은 맞지 않는다. 요청 origin을 그대로 반사하는 구현도 허용 목록 검증 없이 사용하면 안 된다.
문제를 다음처럼 분리해서 본다.
- 브라우저가 요청 자체에 쿠키를 포함했는가? — 쿠키 속성, site 문맥
- 서버가 요청을 받고 인증했는가? — 세션 유효성, 서버 정책
- 브라우저 JavaScript가 응답을 읽을 수 있는가? — CORS 응답 정책
SameSite만으로 CSRF 방어를 끝내면 안 된다
SameSite는 강력한 기본 방어선이지만 애플리케이션의 모든 요청 형태와 하위 도메인 위협을 대신 검증하지 않는다. 상태 변경 요청에는 다음 방어를 조합한다.
- 예측할 수 없는 CSRF 토큰을 발급하고 서버에서 검증한다.
Origin또는 필요에 따라Referer를 허용 목록과 비교한다.GET요청이 서버 상태를 변경하지 않도록 한다.- 중요한 작업에는 재인증이나 사용자 확인을 추가한다.
- 신뢰하지 않는 하위 도메인이 same-site가 될 수 있음을 고려한다.
app.post("/settings/email", requireSession, (req, res) => {
const expectedOrigin = "https://app.example.test";
if (req.get("origin") !== expectedOrigin) {
return res.status(403).json({ message: "허용하지 않은 origin입니다." });
}
if (!verifyCsrfToken(req.session, req.get("x-csrf-token"))) {
return res.status(403).json({ message: "CSRF 검증에 실패했습니다." });
}
updateEmail(req.session.userId, req.body.email);
return res.status(204).end();
});
위 코드는 개념 예시다. 프레임워크가 제공하는 검증된 CSRF 미들웨어와 세션 기능이 있다면 그 계약을 먼저 이해하고 사용하는 편이 좋다.
로컬 개발과 운영 환경의 차이
운영에서는 HTTPS와 실제 도메인을 사용하지만 로컬에서는 http://localhost와 서로 다른 포트를 사용하는 경우가 많다. Secure 쿠키, scheme 차이, 프록시의 X-Forwarded-Proto, 도메인 속성 때문에 개발에서만 또는 운영에서만 문제가 나타날 수 있다.
다음 방법 중 프로젝트에 맞는 방법을 정한다.
- 로컬 개발 서버도 신뢰할 수 있는 개발 인증서로 HTTPS를 사용한다.
- 쿠키 생성 코드를 환경별 문자열 조합이 아니라 정책 함수로 모은다.
- 운영과 같은 reverse proxy 구성을 통합 테스트 환경에 둔다.
- 브라우저 개발자 도구에서 쿠키가 저장되지 않은 이유와 요청 제외 이유를 확인한다.
function buildSessionCookieOptions({ isProduction }) {
return {
httpOnly: true,
secure: isProduction,
sameSite: "lax",
path: "/",
maxAge: 2 * 60 * 60 * 1000,
};
}
이 예제처럼 개발에서 secure를 완화할 수 있지만, 환경 변수가 잘못 설정된 채 운영되는 일을 막아야 한다. 운영 시작 시 HTTPS가 아니면 프로세스를 실패시키는 검증을 추가하는 것도 방법이다.
문제를 재현할 때 확인할 것
- 쿠키가 최초 응답에서 실제로 저장되었는가?
- 요청 URL의 scheme, host, path가 쿠키 범위와 맞는가?
- 콜백이 top-level GET, POST, iframe 중 어떤 문맥인가?
- DevTools가 표시한 차단 또는 제외 사유는 무엇인가?
- 서버 시간과 쿠키 만료 시간이 어긋나지 않았는가?
- 프록시 뒤에서 서버가 원래 요청을 HTTPS로 인식하는가?
- CORS 응답 읽기 실패를 쿠키 미전송으로 착각하지 않았는가?
Strict는 대부분의 크로스 사이트 요청에서 쿠키를 보내지 않는다. Lax는 최상위 이동의 안전한 요청 등 일부를 허용한다. None은 Secure와 함께 사용해야 하며 크로스 사이트 전송을 허용한다.
결론
SameSite는 쿠키가 필요한 실제 요청 문맥을 기준으로 목적별로 정해야 한다. 일반 세션, OAuth의 짧은 상태, iframe 연동을 하나의 쿠키 정책으로 묶지 말고 범위와 수명을 분리한다. 또한 CORS, 쿠키 전송, 서버 세션 검증은 서로 다른 단계이며, SameSite만으로 CSRF 방어가 완성되는 것도 아니다. 로그인 흐름을 설계할 때는 브라우저가 사이트 경계를 어떻게 보는지부터 확인하는 것이 가장 빠른 디버깅 방법이다.